Guide 14 min read Updated

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:

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:

Plain text
        {
  "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.

Plain text
        "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.

Plain text
        // Node.js - jsonwebtoken
const payload = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "https://auth.example.com",   // exact string match
  audience: "https://api.example.com",
});
      
Plain text
        # 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.

Plain text
        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.

Plain text
        "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.

Plain text
        // 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.

Plain text
        // 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.

Plain text
        "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:

Plain text
        // 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);
      
Plain text
        # 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.

Plain text
        // 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:

Plain text
        // jsonwebtoken - clockTolerance in seconds
jwt.verify(token, secret, {
  algorithms: ["HS256"],
  clockTolerance: 60,   // accept tokens expired up to 60 seconds ago
});
      
Plain text
        # 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.

Plain text
        "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.

Plain text
        "iat": 1750087200
      

Primary use cases: iat enables maximum token age checks independent of exp:

Plain text
        // 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
});
      
Plain text
        # 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.

Plain text
        "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:

Plain text
        // 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;
}
      
Plain text
        # 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.

Plain text
        // 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.

Plain text
        // 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;
}
      
Plain text
        # 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.

Plain text
        {
  "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:

Plain text
        {
  "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:

ClaimMeaning
nameFull display name
given_nameFirst name
family_nameLast name
emailEmail address
email_verifiedBoolean - has the email been verified
pictureURL of the profile photo
phone_numberPhone number
localeBCP 47 language tag (e.g., en-US)
zoneinfoIANA timezone (e.g., America/New_York)
updated_atUnix timestamp of last profile update
at_hashAccess token hash - binds ID token to access token
noncePrevents 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:

Plain text
        {
  "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, jti are all registered claims.

  • sub is an opaque, stable user identifier - not an email or sequential integer.

  • iat and nbf match, so the token is valid immediately (no future activation). exp is 60 minutes after issuance (1750090800 - 1750087200 = 3600 seconds).

  • role and tenant_id are short private claims - fine for a closed system where both issuer and verifier agree on their meaning.

  • permissions is a compact array using the resource:action pattern - scoped to what the API immediately needs.

  • https://example.com/claims/subscription is a URI-namespaced public claim to avoid future naming collisions.


Continue reading