JWT Claims Reference (iss, sub, aud, exp, jti)
JWT claims reference (RFC 7519): iss, sub, aud, exp, nbf, iat, jti. Validation rules, clock skew, replay prevention, and custom claim namespacing.
Every JWT payload is a JSON object containing claims - statements about an entity, typically a user, and about the token itself. The seven claims defined in RFC 7519 are not required by the specification, but they are the language every JWT library, identity provider, and verification middleware speaks. Misunderstanding even one of them - particularly exp, aud, or iss - produces authentication bugs that are invisible until they become incidents.
This reference covers all seven registered claims with exact validation rules, the types of mistakes that appear most often in production, clock skew handling, replay attack prevention with jti, and a complete section on custom claims and namespacing. Code examples are in Node.js (jsonwebtoken and jose) and Python (PyJWT).
Each registered claim also has its own dedicated deep-dive page with code examples and FAQs:
- iss — Issuer
- sub — Subject
- aud — Audience
- exp — Expiration Time
- nbf — Not Before
- iat — Issued At
- jti — JWT ID
The three categories of JWT claims
RFC 7519 divides JWT claims into three categories. Understanding the distinction matters when you start adding custom claims.
Registered claims are the seven names defined in RFC 7519 itself: iss, sub, aud, exp, nbf, iat, jti. These names are reserved and have fixed, interoperable meanings. All seven are optional by the spec, but the ecosystem treats several of them - particularly exp - as effectively required.
Public claims are custom claims intended for use across systems or organizations. They must either be registered in the IANA JSON Web Token Claims Registry (which includes the OpenID Connect claims like name, email, picture, phone_number, address) or use a collision-resistant name such as a URI: https://example.com/claims/department.
Private claims are custom claims agreed upon between the token issuer and verifier within a closed system. Short names like role, tenant_id, permissions, and subscription_tier are acceptable private claims as long as both sides of your system agree on their meaning and the names do not conflict with registered or IANA-registered claims.
A real token payload mixes all three:
{
"iss": "https://auth.example.com",
"sub": "user_9a3f2c",
"aud": "https://api.example.com",
"exp": 1750090800,
"iat": 1750087200,
"jti": "a7f3c2e1-4b5d-4e9f-a8c1-2d3e4f5a6b7c",
"role": "admin",
"https://example.com/claims/tenant": "acme-corp"
}
iss, sub, aud, exp, iat, jti are registered. role is a private claim. https://example.com/claims/tenant is a namespaced public claim.
iss - Issuer
iss identifies the principal that issued the token. For most systems this is the URL of the authorization server.
"iss": "https://auth.example.com"
What it should look like: A case-sensitive string. The RFC does not require a URL, but convention and security practice both strongly prefer one. URLs are globally unique, human-readable, and directly match the patterns used by JWKS endpoints and OpenID Connect discovery documents.
Validation rule: Your verifier must compare iss against a hardcoded allowlist of expected issuers. Never accept a token without checking iss, and never build the allowlist dynamically from the token payload itself.
// Node.js - jsonwebtoken
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com", // exact string match
audience: "https://api.example.com",
});
# Python - PyJWT
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
)
Multiple issuers: If your system accepts tokens from multiple identity providers - for example, both Auth0 and an internal auth server - pass an array to issuer. Every string in the array is checked exactly.
jwt.verify(token, getPublicKey, {
algorithms: ["RS256"],
issuer: ["https://auth.example.com", "https://partner-idp.example.com"],
audience: "https://api.example.com",
});
sub - Subject
sub identifies the principal that the token is about - in authentication contexts, this is almost always a user ID.
"sub": "user_9a3f2c"
What it should look like: A string that is unique and stable within the context of the issuer. The combination of iss and sub is globally unique: the same sub value from two different issuers refers to two different principals.
What to use as sub: A database primary key, a UUID, or a generated opaque identifier. Do not use email addresses - email addresses change, can be reassigned, and create user identity confusion. Do not use sequential integers - they leak how many users you have and make user enumeration trivial.
// Good - opaque, stable, non-guessable
"sub": "usr_01HXZ9P2QVKM3NB7FW8A4TJGDC"
// Bad - email (changes, reassignable)
"sub": "alice@example.com"
// Bad - sequential integer (enumerable)
"sub": "12345"
Validation: Most applications use sub to look up the user after verification. The correct pattern: verify the full token (signature, exp, iss, aud) first, then use sub to query your user store. Never use sub before the signature is verified.
aud - Audience
aud identifies the intended recipients of the token. Only the intended recipient should accept the token.
// Single audience
"aud": "https://api.example.com"
// Multiple audiences
"aud": ["https://api.example.com", "https://dashboard.example.com"]
Why aud matters: Without audience validation, a token issued for your mobile app can be presented to your internal admin API and accepted. If a token is stolen from a lower-security endpoint, it works on a higher-security one. The aud claim is what prevents this.
Validation rule: The verifier checks whether its own identifier appears in the aud value. If aud is a string, it must match exactly. If aud is an array, the expected audience must be present somewhere in the array.
Multi-tenant systems: In a multi-tenant API, the audience alone does not identify the tenant. Use a separate private claim - tenant_id, org_id - alongside the registered aud claim. Do not encode tenant identity into aud.
exp - Expiration Time
exp is a Unix timestamp (seconds since the Unix epoch, January 1 1970 UTC) after which the token must not be accepted.
"exp": 1750090800
This is the most security-critical registered claim. A token without exp is valid until the signing key rotates. A stolen token with no expiration is a permanent credential.
How to calculate exp:
// Node.js - let the library do it
const token = jwt.sign({ sub: "user_123" }, secret, { expiresIn: "15m" });
// Or manually
const exp = Math.floor(Date.now() / 1000) + 15 * 60; // 15 minutes from now
const token = jwt.sign({ sub: "user_123", exp }, secret);
# Python - PyJWT accepts timedelta
import datetime
token = jwt.encode(
{
"sub": "user_123",
"exp": datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(minutes=15),
},
secret,
algorithm="HS256",
)
Token lifetime guidance:
Access tokens for high-sensitivity applications (banking, healthcare, admin APIs) should expire in 15 to 30 minutes. Standard API access tokens typically use 15 minutes to 1 hour. Long-lived refresh tokens can use 7 to 30 days. The access token lifespan is the window an attacker has if a token is intercepted - shorter is always safer.
The milliseconds vs seconds mistake:
This is one of the most common JWT bugs in production. JavaScript’s Date.now() returns milliseconds. Unix timestamps in JWT are seconds. A single missing division by 1000 makes your token appear to expire 1000 times sooner, or 1000 times later, than intended.
// Wrong - exp is milliseconds since epoch, ~year 57,000 CE in Unix seconds
const exp = Date.now() + 15 * 60 * 1000;
// Correct - exp is seconds since epoch
const exp = Math.floor(Date.now() / 1000) + 15 * 60;
Clock skew: Distributed systems have imperfect clocks. A token issued by a server running 30 seconds fast appears expired to a verifier running on time. The fix is a clock skew leeway in your verification call:
// jsonwebtoken - clockTolerance in seconds
jwt.verify(token, secret, {
algorithms: ["HS256"],
clockTolerance: 60, // accept tokens expired up to 60 seconds ago
});
# PyJWT - leeway as timedelta or seconds
jwt.decode(token, secret, algorithms=["HS256"], leeway=60)
30 to 60 seconds is standard. Apply leeway only to exp and nbf, never to iat. A token issued in the future is a red flag, not a clock synchronization issue.
nbf - Not Before
nbf is a Unix timestamp before which the token must not be accepted. The token is valid only inside the window nbf ≤ now < exp.
"nbf": 1750087200
When to use nbf: You need to pre-issue tokens that should not be valid immediately - download links for scheduled releases, tokens embedded in emails sent before a deployment, or staggered access for large-scale rollouts.
When to omit nbf: For standard authentication tokens. If you do not need to issue valid-in-the-future tokens, nbf adds operational complexity with no security or functional benefit. Expiration alone covers the standard case.
Validation: Enforce nbf when present with the same clock skew tolerance as exp. Never skip nbf validation because “we don’t use nbf” - an attacker or a misconfigured issuer may include it.
iat - Issued At
iat is a Unix timestamp identifying when the token was issued.
"iat": 1750087200
Primary use cases: iat enables maximum token age checks independent of exp:
// Node.js - max token age validation
const payload = jwt.verify(token, secret, {
algorithms: ["HS256"],
maxAge: "2h", // reject tokens older than 2 hours regardless of exp
});
# Python - max age with PyJWT
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
options={"max_age": 7200}, # 2 hours in seconds
)
Why check iat in addition to exp: exp limits how long a token remains valid from issuance. iat plus maxAge limits how old a token can be when it is presented. These are different constraints. A token issued with a 24-hour exp that was actually created 12 hours ago by a compromised issuer is caught by maxAge: "2h" even though exp hasn’t passed yet.
iat is also useful for token rotation policies - issue a new token on every request if the current one is older than 5 minutes, for example - and for audit logging. Storing iat in logs tells you exactly when each session was created.
A note on iat and replay attacks: Never apply clock skew leeway to iat validation. A token claiming to have been issued in the future is suspicious - it may indicate a replayed or forged token, not a clock synchronization issue.
jti - JWT ID
jti is a unique identifier for this specific token instance. It is a string that must be unique across all tokens issued by a given issuer.
"jti": "a7f3c2e1-4b5d-4e9f-a8c1-2d3e4f5a6b7c"
Primary purpose: Replay attack prevention. A token replay attack is when an attacker captures a valid, unexpired token and reuses it - potentially thousands of times - for additional requests. Signature validity and expiration alone do not prevent this; the token is valid and unexpired. jti combined with a seen-token store does.
How to implement jti-based replay prevention:
// Node.js - Redis-based jti blocklist
const redis = require("redis");
const client = redis.createClient();
async function verifyToken(token) {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
if (!payload.jti) {
throw new Error("Missing jti claim");
}
const seen = await client.exists(`jti:${payload.jti}`);
if (seen) {
throw new Error("Token already used (replay detected)");
}
// Store jti with TTL = remaining token lifetime
const ttl = payload.exp - Math.floor(Date.now() / 1000);
await client.set(`jti:${payload.jti}`, "1", { EX: ttl });
return payload;
}
# Python - Redis-based jti blocklist
import redis
import jwt
import time
r = redis.Redis()
def verify_token(token: str, public_key: str) -> dict:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
)
jti = payload.get("jti")
if not jti:
raise ValueError("Missing jti claim")
key = f"jti:{jti}"
if r.exists(key):
raise ValueError("Token already used (replay detected)")
ttl = int(payload["exp"] - time.time())
r.set(key, "1", ex=max(ttl, 1))
return payload
What to use as jti values: UUIDv4 is the standard choice - it is 36 characters, statistically unique without coordination, and universally supported. crypto.randomUUID() in Node.js 16+ generates UUIDv4 natively.
// Generating a jti when signing
const token = jwt.sign(
{
sub: "user_123",
jti: crypto.randomUUID(), // Node.js 16+, or use uuid package
},
privateKey,
{ algorithm: "RS256", expiresIn: "15m" }
);
When jti is essential vs optional:
jti is essential for tokens that should be valid for exactly one use: password reset tokens, email verification tokens, one-time access grants, payment confirmation tokens, and magic link tokens. For these, implement the full jti store check on every use.
For standard access tokens that are used repeatedly across API calls during a session, jti adds cache infrastructure overhead without meaningful security gain unless you are building a full token revocation system. In that case, jti is the key that lets you revoke individual tokens before they expire.
Claim validation checklist
Your verification code must perform all of the following checks. Omitting any one of them is a security misconfiguration.
Signature first, claims second: Never read claims from a token before verifying the signature. An attacker can put anything in an unverified payload.
Algorithm allowlist: Your verification call must hardcode the accepted algorithms and never read the algorithm from the token header. This prevents the alg: none attack and algorithm confusion vulnerabilities.
exp - required: Reject expired tokens. Never disable expiry checking. Apply clock skew leeway of 30-60 seconds to handle distributed system clock differences.
iss - required for all but single-service tokens: Validate the issuer against a hardcoded string or allowlist. Do not build the expected issuer from request headers or any other user-supplied input.
aud - required for any token that crosses a trust boundary: Validate that your service’s identifier appears in aud. Do not skip this because you trust the issuer - the issuer may issue tokens for other services too.
sub - use it correctly: After full verification, use sub to look up the user. Never trust sub from an unverified token.
nbf - if present, enforce it: Check that the current time is past nbf. Apply the same clock skew leeway as exp. Never skip nbf validation on the assumption that it is optional.
jti - if present and replay prevention matters: Implement the store check. A jti in a token that you do not check is a false sense of security.
// Node.js - complete verification with all required checks
const jwt = require("jsonwebtoken");
function verifyAccessToken(token) {
// Throws if signature invalid, exp passed, iss wrong, or aud wrong
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // hardcoded - never from token header
issuer: "https://auth.example.com",
audience: "https://api.example.com",
clockTolerance: 60, // 60 second clock skew leeway
});
// Additional checks your library may not perform automatically
if (!payload.sub) {
throw new Error("Missing sub claim");
}
return payload;
}
# Python - complete verification
import jwt
def verify_access_token(token: str, public_key: str) -> dict:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # hardcoded
issuer="https://auth.example.com",
audience="https://api.example.com",
leeway=60, # 60 second clock skew
)
if "sub" not in payload:
raise ValueError("Missing sub claim")
return payload
Custom claims: private and public
The seven registered claims rarely carry everything an application needs. In practice, most tokens also carry role, permissions, tenant, subscription tier, or feature flags. These are custom claims.
Private claims (most common): Short names agreed upon between your issuer and your verifiers. Fine for closed systems.
{
"sub": "user_9a3f2c",
"exp": 1750090800,
"iat": 1750087200,
"role": "admin",
"permissions": ["read", "write", "delete"],
"tenant_id": "acme-corp",
"subscription": "pro"
}
Public claims with URI namespacing: When claims need to cross organization boundaries or might be used by third-party systems, use a URI as the claim name. Auth0’s convention is a good reference:
{
"sub": "user_9a3f2c",
"exp": 1750090800,
"https://example.com/claims/role": "admin",
"https://example.com/claims/tenant": "acme-corp"
}
The URI does not need to resolve to a real URL. It is a naming convention to guarantee global uniqueness.
What not to put in custom claims:
Passwords, tokens, secrets, API keys, or anything a user should not be able to read. The payload is base64url-encoded, not encrypted. Any party with the token can decode it without the signing key.
Frequently changing data. Claims are embedded at token issuance. A user’s role embedded at login remains in the token until expiration. If the role changes mid-session, the token still carries the old role. Either use short token lifetimes, implement token revocation via jti, or accept the eventual-consistency behavior and document it.
Unbounded arrays or large objects. JWT payloads accumulate in every HTTP request. An Authorization header is typically limited to 8 KB. A permissions array with 200 entries is not appropriate for a JWT payload - store permissions in your authorization service and look them up at runtime using sub.
OpenID Connect claims
OIDC extends the registered claims with a set of public claims defined in its specification and registered in the IANA JWT Claims Registry. You will encounter these in ID tokens from Auth0, Okta, Google, and AWS Cognito:
| Claim | Meaning |
|---|---|
name | Full display name |
given_name | First name |
family_name | Last name |
email | Email address |
email_verified | Boolean - has the email been verified |
picture | URL of the profile photo |
phone_number | Phone number |
locale | BCP 47 language tag (e.g., en-US) |
zoneinfo | IANA timezone (e.g., America/New_York) |
updated_at | Unix timestamp of last profile update |
at_hash | Access token hash - binds ID token to access token |
nonce | Prevents ID token replay in OIDC flows |
OIDC claims appear in ID tokens, not access tokens. An ID token is for the client application and carries identity information about the authenticated user. An access token is for the resource server and carries authorization information. Do not pass ID tokens to your API as Bearer tokens - they carry different semantics and the aud will be wrong.
Full example payload
A complete, production-quality JWT payload with registered claims, OIDC-style claims, and private custom claims:
{
"iss": "https://auth.example.com",
"sub": "usr_01HXZ9P2QVKM3NB7FW8A4TJGDC",
"aud": "https://api.example.com",
"exp": 1750090800,
"nbf": 1750087200,
"iat": 1750087200,
"jti": "a7f3c2e1-4b5d-4e9f-a8c1-2d3e4f5a6b7c",
"role": "admin",
"permissions": ["users:read", "users:write", "billing:read"],
"tenant_id": "acme-corp",
"https://example.com/claims/subscription": "enterprise"
}
Breaking it down:
-
iss,sub,aud,exp,nbf,iat,jtiare all registered claims. -
subis an opaque, stable user identifier - not an email or sequential integer. -
iatandnbfmatch, so the token is valid immediately (no future activation).expis 60 minutes after issuance (1750090800 - 1750087200 = 3600seconds). -
roleandtenant_idare short private claims - fine for a closed system where both issuer and verifier agree on their meaning. -
permissionsis a compact array using theresource:actionpattern - scoped to what the API immediately needs. -
https://example.com/claims/subscriptionis a URI-namespaced public claim to avoid future naming collisions.
Continue reading
- JWT decoder → - paste any token to decode, verify, and inspect it in your browser
- JWT Signing Algorithms → - HS256, RS256, ES256, PS256, EdDSA with key sizes and code
- JWT Security Vulnerabilities → - alg:none, algorithm confusion, kid injection, and every fix
- Introduction to JWTs → - the three-part structure and when to use JWTs
- JWT Security Vulnerabilities hub → - every vulnerability class as an indexable reference page