JWT Claims Reference: iss, sub, aud, exp, and Custom Claims
Last updated:
A JWT payload is a JSON object whose keys are called claims. RFC 7519 defines seven registered claims (iss, sub, aud, exp, nbf, iat, jti) — each with a specific meaning and validation rule. This reference covers every registered claim, how to validate them in Node.js, and best practices for custom claims.
1. Registered Claims Reference Table
| Claim | Full Name | Type | Description | Validate? |
|---|---|---|---|---|
iss | Issuer | string | URL of token issuer (auth server) | Yes — exact match |
sub | Subject | string | User/entity the token represents | Read — use as user ID |
aud | Audience | string or string[] | Intended recipients of the token | Yes — must include self |
exp | Expiration Time | NumericDate (Unix s) | Token is invalid at/after this time | Yes — reject if expired |
nbf | Not Before | NumericDate (Unix s) | Token is invalid before this time | Yes — reject if before now |
iat | Issued At | NumericDate (Unix s) | When the token was issued | Optional — for age checks |
jti | JWT ID | string (UUID) | Unique token identifier | Optional — replay prevention |
2. A Complete JWT Payload Example
{
"iss": "https://auth.example.com",
"sub": "user:01HX4T3Z2K8QMXYZ",
"aud": "https://api.example.com",
"exp": 1716998400,
"nbf": 1716994800,
"iat": 1716994800,
"jti": "550e8400-e29b-41d4-a716-446655440000",
// Custom claims (application-specific)
"https://example.com/claims/role": "admin",
"https://example.com/claims/plan": "pro",
"email": "alice@example.com",
"email_verified": true
}3. Creating a JWT with Node.js (jose library)
import { SignJWT, importPKCS8 } from 'jose'
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, 'RS256')
const now = Math.floor(Date.now() / 1000) // Unix seconds
const token = await new SignJWT({
// Custom claims
role: 'admin',
plan: 'pro',
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuer('https://auth.example.com')
.setSubject('user:01HX4T3Z2K8QMXYZ')
.setAudience('https://api.example.com')
.setExpirationTime('1h') // exp = now + 3600
.setNotBefore('0s') // nbf = now
.setIssuedAt() // iat = now
.setJti(crypto.randomUUID()) // unique ID
.sign(privateKey)4. Validating a JWT (with full claim checks)
import { jwtVerify, importSPKI } from 'jose'
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, 'RS256')
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 30, // seconds of clock skew tolerance
// algorithms: ['RS256'], // restrict to expected algorithm
})
// payload.exp has been checked — token is not expired
// payload.nbf has been checked — token is active
// payload.iss has been checked — correct issuer
// payload.aud has been checked — correct audience
return payload // typed as JWTPayload
}5. Timestamp Pitfalls
// ❌ Wrong — milliseconds (like Date.now())
const exp = Date.now() + 3600 * 1000 // 1716998400000 — 13 digits
// ✅ Correct — seconds since epoch
const now = Math.floor(Date.now() / 1000)
const exp = now + 3600 // 1716998400 — 10 digits
// Check expiry manually (without a library)
const isExpired = Date.now() / 1000 > payload.exp!
// Parse exp back to a JS Date
const expiresAt = new Date(payload.exp! * 1000)6. Custom Claims Best Practices
// ✅ Namespaced private claims (collision-resistant)
{
"https://myapp.com/claims/role": "admin",
"https://myapp.com/claims/org_id": "org_123"
}
// ✅ OIDC standard public claims (use these when the concept matches)
{
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Smith",
"picture": "https://...",
"given_name": "Alice",
"family_name": "Smith"
}
// ❌ Short generic names — risk collision with future standards
{
"role": "admin", // no namespace
"plan": "pro", // ambiguous
"permissions": [...] // not in IANA registry
}
// TypeScript type for your token payload
interface AppJWTPayload {
sub: string
exp: number
iat: number
aud: string
iss: string
'https://myapp.com/claims/role': 'admin' | 'user'
email?: string
}7. Decoding Without Verification (client-side only)
// Browser / Node.js — base64url decode the payload
function decodeJwtPayload(token: string) {
const [, payloadB64] = token.split('.')
// atob handles base64url (add padding if needed)
const padded = payloadB64.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(padded.padEnd(padded.length + (4 - payloadB64.length % 4) % 4, '='))
return JSON.parse(json)
}
// ⚠️ This does NOT verify the signature
// Use ONLY for reading non-sensitive UI data (e.g., exp for countdown)
// NEVER use for authorization decisionsUse Jsonic's JWT decoder to inspect claims in the browser without sending the token to any server.
Frequently Asked Questions
What are the required JWT claims and which are optional?
RFC 7519 defines seven registered claim names, all of which are OPTIONAL at the protocol level: iss (issuer), sub (subject), aud (audience), exp (expiration time), nbf (not before), iat (issued at), and jti (JWT ID). None are technically required by the spec. However, in practice: exp is nearly always required by libraries — most JWT validators reject tokens without exp or with an expired exp. aud is required whenever multiple services share a signing key — without it, a token issued for service A can be replayed against service B. iat is widely included for clock-skew analysis and token age checks. The minimum practical JWT payload for a web authentication token should include: sub (who the token identifies), exp (when it expires), iat (when it was issued), and aud (which service it is for). Additional claims like roles, permissions, or email are application-specific and go in custom claims.
What does the iss (issuer) claim mean and how is it validated?
iss identifies the principal that issued the JWT — typically the URL of your authorization server or identity provider. Examples: "https://accounts.example.com", "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123". When validating a JWT, the receiving service should check that iss exactly matches the expected issuer. This prevents token confusion — if a malicious actor obtains a token from a different issuer that happens to use the same signing key, checking iss prevents it from being accepted. Libraries like jsonwebtoken (Node.js) accept an issuer option: jwt.verify(token, secret, { issuer: "https://accounts.example.com" }). OIDC (OpenID Connect) requires the iss to be the URL of the provider's authorization server — the same value as the iss field in the provider's discovery document at /.well-known/openid-configuration.
What does the aud (audience) claim mean?
aud (audience) identifies the recipients the JWT is intended for. It is either a string or an array of strings. Each recipient must verify that its own identifier is in the aud claim — if the identifier is not present, the JWT must be rejected. This prevents audience confusion attacks: if service-a and service-b share the same JWT signing key (common in microservice architectures), a token with aud: "service-a" should not be accepted by service-b. Validation: jwt.verify(token, secret, { audience: "https://api.example.com" }). Multiple audiences: aud: ["https://api.example.com", "https://admin.example.com"] — both services can accept this token. Convention: use the full URL of the API/resource server as the audience (e.g., https://api.example.com), not a short name that could collide across environments.
How does the exp claim work and what is clock skew?
exp (expiration time) is a NumericDate — a Unix timestamp (seconds since epoch, not milliseconds). A JWT is invalid at or after the time specified by exp. A token with exp: 1716998400 is valid until 2026-05-29T16:00:00Z, and invalid at exactly that second. Common values: access tokens typically expire in 15 minutes to 1 hour; refresh tokens in days to weeks. Clock skew is the difference in wall-clock time between the token issuer and the validator. If the issuer's clock is 30 seconds ahead of the validator's, a token that expired at the issuer might still appear valid to the validator. Most JWT libraries allow a clockTolerance parameter to account for this: jwt.verify(token, secret, { clockTolerance: 30 }) — this adds 30 seconds of leeway to both exp and nbf checks. The nbf (not before) claim is the inverse: the token is not valid before this time.
What is the jti claim and when should I use it?
jti (JWT ID) is a unique identifier for the JWT, usually a UUID. Its primary use is preventing replay attacks: if a token is intercepted in transit and replayed, a server that tracks used jti values can detect and reject the duplicate. Implementing jti replay prevention requires a database or cache lookup on every token validation — for high-volume APIs, this is a performance cost. Common approaches: (1) store revoked jti values in Redis with TTL equal to the token lifetime (only track tokens you want to revoke, not all tokens); (2) use short-lived tokens (15 min) without jti tracking — the attack window is small enough that replay is impractical; (3) use refresh token rotation with jti to detect theft. For most web applications, short exp plus HTTPS is sufficient — jti tracking is only needed for financial, healthcare, or high-security contexts where replay of a single valid request could cause harm.
What is the sub (subject) claim and what should I put in it?
sub (subject) identifies the principal the JWT is about — for authentication tokens, this is the user identifier. It should be a string that is locally unique within the issuer context and not reassignable (if a user is deleted, their sub should not be reused for a new user). Common choices: a database UUID/ULID ("user:01HX..."), an opaque user ID ("u_12345"), or a stable external ID. Do NOT use email as sub — emails change. Do NOT use usernames — they can be reassigned. The OIDC specification requires sub to be stable across sessions (same user always gets the same sub) and case-sensitive. For machine-to-machine (M2M) tokens, sub is typically the client application ID. For federated identity, sub is the user ID from the identity provider (Google, GitHub, etc.) and should be combined with iss to form a globally unique identifier, since two different providers may issue the same sub value.
What are private claims and how should I name custom JWT claims?
RFC 7519 defines three categories of claims: Registered (the seven standardized claims), Public (registered in the IANA JWT Claims Registry to avoid collisions — e.g., email, name, given_name from OIDC), and Private (custom claims defined by producers and consumers). For private claims, use a collision-resistant name to avoid conflicts. The convention is to namespace them: use a URI like "https://myapp.com/claims/role" or an application-specific prefix like "myapp_role". Avoid short generic names like "role", "plan", or "permissions" — these may conflict with claims used by identity providers, third-party libraries, or future RFC additions. OpenID Connect defines standard user profile claims (email, email_verified, name, picture, etc.) in the IANA registry — use these standard claim names when the concept matches, rather than defining your own. For authorization decisions in access tokens, place roles/permissions directly in the token to avoid additional database lookups on every request.
How do I read JWT claims in Node.js without validating the signature?
A JWT payload is a base64url-encoded JSON string. To read claims without verifying: const [, payloadB64] = token.split("."); const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")). This is useful for reading non-sensitive claims (like sub or exp) client-side in the browser, or for debugging. Warning: NEVER make authorization decisions based on claims decoded this way — the signature has not been verified. The payload can be forged by modifying the token. For trusted reads in a backend service, always use jwt.verify() (jsonwebtoken) or jose verify() to verify the signature before using the claims: const { payload } = await jose.jwtVerify(token, secret, { issuer, audience }). The Jsonic JWT decoder tool lets you inspect claims in the browser without sending the token to any server.
Further reading and primary sources
- RFC 7519 — JSON Web Token — The IETF specification for JWT, including all registered claim definitions
- IANA JWT Claims Registry — Complete list of registered and public JWT claim names
- How JWT Works (Jsonic) — JWT structure: header, payload, signature — HS256 vs RS256, and the sign/verify flow
- JWT Refresh Token Guide (Jsonic) — Access + refresh token patterns, rotation, silent refresh, and revocation
- JWT vs Session Auth (Jsonic) — When to use stateless JWT vs stateful sessions — tradeoffs and revocation