JWT Claims

7 interview angles 7 min read source

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 42 from issuer A and sub 42 from 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 email
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 sub claim).
  • 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 aud check — cross-app token reuse.
  • No iss check — 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.
  • jti not 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.
  • iat is the expiry.”iat is issued-at, exp is expiration. Often confused; remember iat < 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 aud and 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 check aud matches its own identifier.
  • sub vs email for identifying users?”sub is opaque, stable, issuer-scoped. email changes (renames, deletions, re-assignment). Match on sub. email is for display / contact, not identity.
  • “What’s jti for?” — JWT ID; unique per token. Used for replay prevention (track seen jtis) and revocation (blocklist by jti). 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 leeway parameter (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.