By AndyPublished
JWT Claims Explained: iss, sub, aud, exp, nbf, iat, jti
Claim Categories
RFC 7519 defines three categories of claims:
- ·Registered claims: Predefined claim names with a specific meaning: iss, sub, aud, exp, nbf, iat, jti. None are required by the spec, but most are required by good security practice. Their names are short by design, compactness is a JWT goal.
- ·Public claims: Names that are registered with IANA to avoid collisions. Examples include
email,name,picture, andphone_number(used by OpenID Connect). - ·Private claims: Application-specific names agreed between the token issuer and consumer. Use URI-style namespacing (e.g.,
https://myapp.com/roles) to avoid collisions with standard names.
iss, Issuer
The iss (Issuer) claim identifies the principal that issued the token. For OAuth 2.0 and OIDC, this is typically the authorization server's base URL:
"iss": "https://auth.example.com/"
Validation: If your application receives tokens from a specific issuer, you must validate that iss exactly matches the expected value. This prevents token substitution attacks where a token issued by a different (possibly malicious) auth server is accepted. String comparison must be exact, do not use prefix matching.
Most JWT libraries accept an issuer parameter during verification and will reject tokens with a mismatched iss automatically.
sub, Subject
The sub (Subject) claim identifies the principal that is the subject of the JWT, in practice, usually a unique user identifier.
"sub": "user_a8f3c2d1"
The sub value must be locally unique within the context of the issuer and must be a case-sensitive string. It is typically an opaque user ID (not an email address) to avoid coupling to mutable identity attributes. The combination of iss and sub is globally unique and can serve as a stable identifier across sessions.
aud, Audience
The aud (Audience) claim identifies the recipients that the JWT is intended for. This is either a single string or an array of strings:
"aud": "https://api.example.com" // or multiple audiences: "aud": ["https://api.example.com", "https://reports.example.com"]
Validation: Each service that receives a JWT must verify that its own identifier appears in the aud claim. A token intended for your reporting API should never be accepted by your payments API. This prevents token forwarding attacks.
exp, Expiration Time
The exp (Expiration Time) claim is a Unix timestamp (seconds since the Unix epoch) after which the JWT must not be accepted:
"exp": 1716480961 // 2024-05-23T18:29:21Z
Validation: Reject any token where the current time is at or after the exp value. Allow a small clock skew (typically 60 seconds) to accommodate minor time differences between distributed servers.
Short lifetimes are a security feature, not a nuisance. Access tokens typically expire in 5–60 minutes. If a token is stolen, the attacker's window of use is limited by the expiry time. Pair short-lived access tokens with longer-lived refresh tokens to maintain user sessions without requiring frequent re-authentication.
nbf, Not Before
The nbf (Not Before) claim is a Unix timestamp before which the JWT must not be accepted:
"nbf": 1716477361 // valid only after this time
This is useful when you want a token to become valid at a specific future time, for example, a scheduled batch job authorization that should only activate at midnight. In practice, nbf is used less frequently than exp. If present, validate it by rejecting tokens where the current time is before the nbf value (allowing the same clock skew tolerance you apply to exp).
iat, Issued At
The iat (Issued At) claim records when the token was created:
"iat": 1716477361 // 2024-05-23T17:29:21Z
iat is informational, the spec does not require receivers to reject tokens based on it. However, it is useful for monitoring token age, detecting anomalously old tokens, and auditing. Some implementations use iat to enforce a maximum token age independent of exp (for example, rejecting any token issued more than 24 hours ago regardless of its expiry claim).
jti, JWT ID
The jti (JWT ID) claim provides a unique identifier for the token:
"jti": "a8f3c2d1-6e45-4b2a-9f87-12c3d4e5f6a7"
jti is primarily used to prevent replay attacks. If an attacker intercepts a token and re-submits it, a server that maintains a log of seen jti values can detect and reject the duplicate.
This requires storing seen JTIs server-side for at least the duration of the token's validity window, which partially trades away the stateless benefits of JWTs. It is most useful for high-security scenarios like one-time authorization codes or webhook signatures.
Clock Skew and exp Validation
In distributed systems, the clocks on different servers are never perfectly synchronised. NTP (Network Time Protocol) keeps them close, but a 2–5 second drift is common; in some cloud environments or containerised workloads, drift can reach 30–60 seconds if NTP is misconfigured.
This creates a practical problem: a token issued by Server A with exp = T + 300 might arrive at Server B when Server B's clock already reads T + 302. Without a tolerance, the token is rejected as expired even though it was valid at issuance.
The standard solution is a clock skew tolerance, typically 60 seconds, applied symmetrically to both exp and nbf:
// Validation with clock skew tolerance
const CLOCK_SKEW_SECONDS = 60
const now = Math.floor(Date.now() / 1000)
// exp check: allow up to 60s after expiry
if (payload.exp && now > payload.exp + CLOCK_SKEW_SECONDS) {
throw new Error('Token expired')
}
// nbf check: allow tokens issued up to 60s in the future
if (payload.nbf && now < payload.nbf - CLOCK_SKEW_SECONDS) {
throw new Error('Token not yet valid')
}Most JWT libraries expose a clockTolerance or leeway option. Set it to 30–60 seconds. Do not set it higher — a large tolerance defeats the purpose of short-lived tokens.
OpenID Connect Standard Claims
OpenID Connect extends JWT with a set of standard identity claims for ID tokens. These are registered with IANA and have interoperable meanings across all OIDC providers:
| Claim | Type | Description |
|---|---|---|
| name | string | User's full display name |
| given_name | string | First name |
| family_name | string | Last name |
| string | User's email address | |
| email_verified | boolean | Whether the email address has been verified |
| picture | string | URL of the profile picture |
| phone_number | string | Phone number in E.164 format |
| locale | string | BCP 47 locale tag (e.g., en-GB) |
| nonce | string | Anti-replay value set by the client in the auth request |
| at_hash | string | Hash of the access token, binds ID token to access token |
| azp | string | Authorized party, client ID the token was issued to |
OIDC providers like Auth0, Google, Okta, and AWS Cognito include a subset of these claims in their ID tokens based on the OAuth scopes requested (e.g., requesting the profile scope returns name and picture).
Private, Custom Claims and Naming Conventions
Beyond the seven registered claims, applications commonly add their own claims:
{
"sub": "user_123",
"exp": 1716480961,
"email": "jane@example.com",
"name": "Jane Developer",
"roles": ["admin", "editor"],
"org_id": "org_456",
"https://myapp.com/permissions": ["read", "write"]
}Use URI-prefixed names for custom claims to avoid collisions with current or future standard claims. Keep custom claim values small, each claim adds to the token size, which matters for HTTP header and cookie size limits.
Practical sizing constraints: HTTP headers are typically limited to 8 KB by reverse proxies (nginx, HAProxy). Browser cookies are limited to 4 KB per cookie. A JWT with many large claims can silently fail to transmit if these limits are exceeded. If your tokens are growing large, move infrequently-needed claims out of the JWT and into a dedicated user-info API call.