JWT Claims
The payload is a JSON object of “claims” — assertions about the user / token. RFC 7519 defines seven registered claims with standard names; everything else is custom (public or private).
Registered claims (RFC 7519)
All optional. None required by the spec, but in practice you’ll see most of them.
| Claim | Name | Type | Means |
|---|---|---|---|
iss |
Issuer | string | who created the token (https://auth.example.com) |
sub |
Subject | string | who the token represents (user ID) |
aud |
Audience | string or array | who the token is for (your app’s identifier) |
exp |
Expiration | epoch seconds | token invalid after this time |
nbf |
Not Before | epoch seconds | token invalid before this time |
iat |
Issued At | epoch seconds | when the token was created |
jti |
JWT ID | string | unique token identifier (for replay prevention, revocation) |
Three-letter names to keep tokens compact.
iss — Issuer
{ "iss": "https://auth.example.com" }
Identifies who issued the token. Verifier compares against expected issuer:
jwt.decode(token, key, algorithms=["RS256"], issuer="https://auth.example.com")
# Raises if iss doesn't match
Used to distinguish tokens from multiple issuers, or to discover the JWKS for verification (<iss>/.well-known/jwks.json).
sub — Subject
{ "sub": "user_42" }
The “who” of the token. Typically a user ID. Should be:
- Stable — doesn’t change when the user changes their email or name.
- Opaque to clients if possible (UUID rather than meaningful ID).
- Scoped to the issuer — sub
42from issuer A and sub42from issuer B aren’t the same user.
For OIDC, sub is the canonical user identifier across the OP.
aud — Audience
{ "aud": "myapp.example.com" }
// or array:
{ "aud": ["myapp.example.com", "api.example.com"] }
Identifies who the token is FOR. The recipient must verify it’s in the audience:
jwt.decode(token, key, algorithms=["RS256"], audience="myapp.example.com")
Critical security check. Without it: a valid token meant for app B (signed by the same issuer) works at app A. Token confusion / scope creep.
For OIDC: ID token’s aud is the client_id; access token’s aud is the API.
exp — Expiration
{ "exp": 1716320000 }
Unix epoch seconds. After this time, reject the token.
jwt.decode(token, key, algorithms=["RS256"]) # raises ExpiredSignatureError if past exp
Libraries handle expiry automatically. Disable only for tokens that legitimately have no expiry (rare and dangerous — refresh tokens with explicit revocation are better).
Conventional lifetimes:
- Access tokens: 5-60 minutes.
- ID tokens (OIDC): 15-60 minutes.
- Refresh tokens: hours to days, with rotation.
- API keys (issued as JWTs): months to never (with revocation).
See 04_access_refresh_tokens.md.
nbf — Not Before
{ "nbf": 1716316400 }
Token is invalid BEFORE this time. Less commonly used. Use cases:
- Time-delayed activation (“this token works starting tomorrow”).
- Clock-skew tolerance (server clocks differ slightly).
Most libraries enforce nbf automatically when present.
iat — Issued At
{ "iat": 1716316400 }
When the token was created. Used for:
- Logging / auditing.
- Detecting old tokens you want to invalidate (“all tokens issued before this date are revoked”).
- Calculating token age relative to other times.
Not security-critical on its own; pair with exp.
jti — JWT ID
{ "jti": "550e8400-e29b-41d4-a716-446655440000" }
Unique identifier per token (typically UUID). Used for:
- Replay prevention: server tracks seen
jtis for short window; rejects duplicates. - Revocation: blocklist of revoked
jtis. - Audit: trace a specific token through logs.
jti is the JWT’s identity. Some people refer to this as “JWI” colloquially, though that abbreviation isn’t standard.
Public claims
Names registered in the IANA JSON Web Token Claims Registry but not in the core RFC. Examples:
| Claim | Means |
|---|---|
name |
full name |
given_name, family_name, middle_name |
name parts |
nickname, preferred_username |
display names |
email, email_verified |
|
picture |
URL to avatar |
locale |
preferred locale (e.g., en-US) |
zoneinfo |
timezone |
phone_number, phone_number_verified |
phone |
address |
structured address |
updated_at |
when the user profile was last updated |
Most of these come from OIDC standard claims. ID tokens populate them.
Private claims
Anything you define. Avoid name collisions with registered / public claims. Convention: prefix with your domain.
{
"sub": "user_42",
"https://example.com/role": "admin",
"https://example.com/tenant": "acme-corp",
"https://example.com/permissions": ["read:posts", "write:posts"]
}
Or use short custom names if you control both ends:
{
"sub": "user_42",
"role": "admin",
"tenant": "acme-corp",
"perms": ["read:posts", "write:posts"]
}
The IANA registry exists so that public claim names don’t collide; for purely internal tokens, short names are fine.
Common custom claims
Real-world JWTs often include:
| Claim | Use |
|---|---|
role / roles |
RBAC roles |
permissions / scopes / scp |
granular permissions |
tenant_id / org |
multi-tenant SaaS |
groups |
IdP group membership |
acr, amr |
authentication context / methods (OIDC; see ../sso/09_mfa_step_up.md) |
auth_time |
when the user actually authenticated (may differ from iat) |
Standard OAuth 2.0 scope claim is scope (space-separated string) or scp (array). Stick with one across your services.
Don’t put PII in claims
The payload is base64-encoded JSON, not encrypted. Anyone with the token reads everything. Don’t include:
- Social Security Numbers / national IDs.
- Full credit card numbers.
- Passwords or password hashes.
- Sensitive medical info.
- Internal system credentials.
Acceptable:
- User ID (the
subclaim). - Display name / email (per the OIDC standard, if you accept the privacy implication).
- Coarse roles / permissions.
If you need confidentiality, use JWE (encrypted JWT). See 08_jose_family.md.
Claim size and the bigger picture
JWTs ride along on every request. Adding many claims bloats every HTTP header:
500-byte JWT × 100 requests/sec/client = 50 KB/sec ingress per client header
For a typical service, JWT size matters less than payload size, but very large JWTs (>4 KB) can hit:
- HTTP header limits (default 8 KB in nginx; 16 KB in some).
- Cookie size limits (4 KB per cookie).
- Visible overhead in mobile bandwidth.
Trim claims to what verifiers actually need. Fetch the rest from the OP’s /userinfo endpoint on demand.
Time claims and clock skew
Server clocks drift. Strict expiry checks fail when a server is 30 seconds ahead. Libraries handle this with a “leeway”:
jwt.decode(token, key, algorithms=["RS256"], leeway=60) # tolerate 60s skew
Reasonable leeway: 30-60 seconds. More opens replay windows; less risks legitimate users.
Run NTP on your servers and your IdP’s servers. Don’t make leeway too generous.
Common pitfalls
- No
exp— token valid forever. If leaked, permanent compromise. - Excessive
exp— 1-year-lifetime access token. If leaked, year-long compromise. - No
audcheck — cross-app token reuse. - No
isscheck — accepting tokens from arbitrary issuers signed by the same key (unlikely but possible). - PII in payload — anyone with token logs has user data.
- Putting roles in JWT and never updating — user demoted; their JWT still has admin role until expiry.
jtinot tracked — replay attacks possible within token lifetime.
Common interview confusions
- “All claims are required.” — none are. The spec defines names for common claims; using them is convention, not requirement.
- “
iatis the expiry.” —iatis issued-at,expis expiration. Often confused; rememberiat<exp. - “Claims are encrypted.” — base64-encoded JSON. Visible to anyone with the token.
Interview angle
- “What are JWT claims?” — assertions about the user / token in the payload. Three categories: registered (
iss,sub,aud,exp,nbf,iat,jti— defined in RFC 7519), public (IANA registry, e.g.,email,name), private (your custom claims). - “What’s
audand why must it be validated?” — Audience: who the token is FOR. Without checking, a valid token for app B works at app A — same issuer, different intended recipient. The verifier must checkaudmatches its own identifier. - “
subvsemailfor identifying users?” —subis opaque, stable, issuer-scoped.emailchanges (renames, deletions, re-assignment). Match onsub.emailis for display / contact, not identity. - “What’s
jtifor?” — JWT ID; unique per token. Used for replay prevention (track seenjtis) and revocation (blocklist byjti). Some people call this concept “JWI” though that’s not standard. - “Should you put PII in JWTs?” — no. Payload is base64-encoded, not encrypted. Anyone with the token reads everything. For confidentiality use JWE (rare) or just a server-side session with an opaque ID.
- “How do you handle clock skew between issuer and verifier?” — libraries support a
leewayparameter (typically 30-60 seconds tolerance). Run NTP on servers. Don’t make leeway excessive or you open replay windows. - “How does putting a role in a JWT interact with role changes?” — the JWT keeps the old role until expiry. For real-time permission updates, either short-lived tokens (re-fetched often) or check authoritative state on each request (defeats stateless advantage). Most teams accept the staleness for short-lived access tokens.