Skip to main content
JWT Guide

JWT Claims Explained —
iss, sub, aud, exp, iat, nbf, jti

What each registered claim means, which ones most libraries skip by default, and what goes wrong when you don't validate them.

8 min read·Updated May 2026

JWT claims explained: the payload of every JWT is just a JSON object — the key-value pairs inside it are claims. Seven of those keys are reserved by the IANA standard, and understanding exactly what each one does, which libraries validate them automatically, and what an attacker can do when you skip them is the difference between a correctly implemented authentication system and one that silently accepts tokens it should reject.

Claims, Registered vs Public vs Private

Every JWT payload is a plain JSON object. The pairs inside it are called claims. Claims come in three flavours:

Registered Claims

Defined by RFC 7519 and catalogued by IANA. The seven short keys — iss, sub, aud, exp, nbf, iat, jti — are deliberately short to keep the encoded token compact. Every major library understands how to validate them.

Public Claims

Custom claims intended to be shared between systems. To avoid collisions they should be namespaced with a URI — e.g. "https://example.com/role": "admin". The namespace is just a string; no HTTP request is made to it.

Private Claims

Application-specific claims agreed upon between the parties exchanging tokens. No registration or namespacing is needed — but be aware that a colliding key name in a different context will cause silent confusion.

A real decoded JWT payload looks like this — all three claim types together:

Decoded JWT Payload base64url-decoded
{
  /* ── registered claims ── */
  "iss": "https://auth.example.com",
  "sub": "user_7f3a9c",
  "aud": "payment-api",
  "exp": 1716242622,
  "iat": 1716239022,
  "nbf": 1716239022,
  "jti": "01hwz8q3k4m5n6p7r8s9t0uv",

  /* ── public claim (namespaced) ── */
  "https://example.com/role": "editor",

  /* ── private claims (app-specific) ── */
  "org_id": "acme-corp",
  "plan":   "pro"
}

This payload is Base64Url-encoded in the wire format — it is not encrypted. Anyone holding the token can read every field.

exp — Expiration Time (and the Seconds vs Milliseconds Trap)

exp (expiration time) is a Unix timestamp in seconds since the epoch. It is the most commonly validated claim — but also the source of one of the most common JWT bugs in the wild.

The bug: JavaScript's Date.now() returns milliseconds. Developers who forget to divide by 1000 produce a token with an exp value 1,000 times larger than intended. Most libraries will accept it as valid because the timestamp is technically in the future — by about 56,000 years.

Wrong — milliseconds passed directly sign.js
const jwt = require('jsonwebtoken');

// ❌ Date.now() returns milliseconds — token expires in ~56,000 years
const token = jwt.sign(
  { sub: 'user_7f3a9c', exp: Date.now() + 3600 * 1000 },
  process.env.JWT_SECRET
);
// exp claim will be something like: 1716242622000
//                                   ^^^^^^^^^^^^^^ ms, not seconds
Correct — seconds since epoch sign.js
const jwt = require('jsonwebtoken');

// ✅ Divide by 1000 to convert ms → seconds, or use the expiresIn shorthand
const token = jwt.sign(
  { sub: 'user_7f3a9c' },
  process.env.JWT_SECRET,
  { expiresIn: '1h', algorithms: ['HS256'] }  // preferred
);
// exp claim: 1716242622  ← seconds, expires in exactly 1 hour

// If you must set exp manually:
const nowSeconds = Math.floor(Date.now() / 1000);
const tokenManual = jwt.sign(
  { sub: 'user_7f3a9c', exp: nowSeconds + 3600 },
  process.env.JWT_SECRET,
  { algorithm: 'HS256' }
);

What happens when exp is missing entirely? Most libraries treat a token with no exp claim as never-expiring and accept it indefinitely. A token issued years ago with no expiry will still pass signature verification today. Always set exp. For access tokens, 15 minutes ('15m') is the standard.

iat — Issued At (and Key Rotation Detection)

iat (issued at) records the Unix timestamp at which the token was created. Libraries include it automatically in most cases, but they do not validate it by default — your application code has to use it explicitly.

The critical use case is key rotation detection. If you discover a compromised signing secret at 14:00 and rotate to a new secret, any token issued before 14:00 is suspect — even if it has not yet expired. By recording your rotation timestamp server-side and rejecting tokens where iat is earlier than the rotation, you can immediately invalidate all pre-rotation tokens without waiting for them to expire.

Node.js — reject tokens issued before key rotation auth-middleware.js
const jwt = require('jsonwebtoken');

// Store this in Redis / your config when you rotate the secret
const KEY_ROTATED_AT = 1716200000; // Unix seconds — set when you rotated

function verifyToken(token) {
  const payload = jwt.verify(token, process.env.JWT_SECRET, {
    algorithms: ['HS256'],
  });

  // iat is not validated by the library — check it ourselves
  if (payload.iat < KEY_ROTATED_AT) {
    throw new Error('Token was issued before the last key rotation');
  }

  return payload;
}

iat can also be used to enforce a maximum token age independent of exp — useful when you want to cap how long a token can remain in use even if someone set an unusually long expiry.

nbf — Not Before (Pre-issued Tokens)

nbf (not before) is the counterpart to exp: a token must not be accepted before this Unix timestamp. A request arriving before nbf should be rejected with the same 401 you would return for an expired token.

The canonical use case is pre-issued tokens for scheduled actions: generate an email verification link now, but make it valid only 10 minutes from now so that if someone clicks it immediately they are told to wait. Another common pattern is generating a batch of time-windowed API tokens in advance — each one has a different nbf and exp to create non-overlapping windows.

Python — PyJWT sign and verify with nbf tokens.py
import jwt, time, os

SECRET = os.environ["JWT_SECRET"]

def issue_delayed_token(user_id: str, delay_seconds: int = 600) -> str:
    """Issue a token that won't be valid for `delay_seconds` seconds."""
    now = int(time.time())
    payload = {
        "sub": user_id,
        "iat": now,
        "nbf": now + delay_seconds,          # valid 10 min from now
        "exp": now + delay_seconds + 3600,   # expires 1 h after it becomes valid
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def verify_token(token: str) -> dict:
    # PyJWT validates exp AND nbf automatically — raises ImmatureSignatureError
    # if the current time is before nbf
    return jwt.decode(token, SECRET, algorithms=["HS256"])

# Testing nbf enforcement:
token = issue_delayed_token("user_7f3a9c", delay_seconds=600)

try:
    payload = verify_token(token)
except jwt.ImmatureSignatureError:
    print("Token not yet valid — nbf is in the future")

Library behaviour: both jsonwebtoken (Node) and PyJWT validate nbf automatically. The token is rejected with an ImmatureSignatureError / NotBeforeError if the current time is before nbf. Both libraries also support a leeway option (a few seconds of clock-skew tolerance) if your servers are not perfectly synchronised.

iss — Issuer (Cross-system Token Reuse Prevention)

iss (issuer) identifies the principal that issued the JWT — typically a URL such as https://auth.example.com or the name of your auth service. Its purpose is to prevent a valid token issued by one system from being accepted by a completely different system.

Consider a company with separate auth, payment, and admin services, all signing with HS256. If the payment service and the admin service share the same signing secret but don't validate iss, a legitimate user token issued for the payment service will pass signature verification against the admin service. The user now has admin access they were never granted.

The critical detail: most libraries do not validate iss by default. You must explicitly pass the expected issuer value at verification time.

Node.js — jsonwebtoken: sign and verify iss auth.js
const jwt = require('jsonwebtoken');

// Signing — include iss in the payload
const token = jwt.sign(
  { sub: 'user_7f3a9c' },
  process.env.JWT_SECRET,
  {
    algorithm:  'HS256',
    expiresIn:  '15m',
    issuer:     'https://auth.example.com',  // sets iss in payload
  }
);

// Verifying — pass issuer option to enforce iss validation
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  issuer:     'https://auth.example.com',  // ❌ wrong iss → JsonWebTokenError
});
// Without the issuer option, ANY iss value (or no iss at all) is accepted
Python — PyJWT: sign and verify iss auth.py
import jwt, os

SECRET  = os.environ["JWT_SECRET"]
ISSUER  = "https://auth.example.com"

# Signing
token = jwt.encode(
    {"sub": "user_7f3a9c", "iss": ISSUER},
    SECRET,
    algorithm="HS256",
)

# Verifying — issuer= triggers iss validation
payload = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    issuer=ISSUER,   # omit this and PyJWT ignores iss entirely
)
# Raises jwt.InvalidIssuerError if iss doesn't match

sub — Subject (Who the Token Is About)

sub (subject) identifies the entity the JWT refers to — almost always the authenticated user. The RFC requires the value to be locally or globally unique within the context of the issuer. In practice, this means a stable, opaque identifier: a database primary key, a UUID, or a prefixed ID like user_7f3a9c.

Why not use email as sub? Emails change. A user who changes their email address effectively becomes a different identity in your system if the email is used as sub. Tokens issued before the change will contain the old email, creating a mismatch with the current user record. Any system that looks up users by sub will fail — or worse, silently load the wrong account if the old email is reassigned to someone else.

Accessing sub after verification middleware.js
function authMiddleware(req, res, next) {
  const raw   = req.headers.authorization ?? '';
  const token = raw.replace(/^Bearer\s+/i, '');

  let payload;
  try {
    payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer:     'https://auth.example.com',
    });
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  // payload.sub is the stable user identifier — use it for DB lookups
  req.userId = payload.sub;  // e.g. "user_7f3a9c"
  next();
}

// In the route handler:
app.get('/profile', authMiddleware, async (req, res) => {
  const user = await db.findUser(req.userId);  // look up by sub, not email
  res.json(user);
});

aud — Audience (Which Service the Token Is For)

aud (audience) declares which service or recipient the token is intended for. It prevents a valid token for one service being replayed against a different service. If you have a mobile-app token and an admin-api service, an attacker who steals a user's mobile token should not be able to call admin endpoints with it — even if both services share a signing key.

The aud value can be a string or an array of strings for multi-audience tokens (useful when a single token needs to be accepted by multiple services in a trusted group).

Node.js — single and multi-audience tokens auth.js
const jwt = require('jsonwebtoken');

// Single audience — only the payment-api should accept this token
const singleAud = jwt.sign(
  { sub: 'user_7f3a9c' },
  process.env.JWT_SECRET,
  { algorithm: 'HS256', expiresIn: '15m', audience: 'payment-api' }
);

// Multi-audience — accepted by both reporting-api and analytics-api
const multiAud = jwt.sign(
  { sub: 'svc_dashboard' },
  process.env.JWT_SECRET,
  {
    algorithm: 'HS256',
    expiresIn:  '1h',
    audience:  ['reporting-api', 'analytics-api'],  // aud is an array in the token
  }
);

// Verifying — the payment-api passes its own name as audience
jwt.verify(singleAud, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  audience:   'payment-api',  // must be in token's aud — else JsonWebTokenError
});

// The admin-api trying to accept the payment-api token:
jwt.verify(singleAud, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  audience:   'admin-api',    // ❌ "jwt audience invalid" — correctly rejected
});

Important: like iss, aud is not validated by default in jsonwebtoken or PyJWT. If you omit the audience option at verification time, any token — regardless of what its aud says — is accepted by every service.

jti — JWT ID (One-time Tokens and Refresh Token Rotation)

jti (JWT ID) provides a unique identifier for the token. By itself it adds no security — it only becomes meaningful when the server stores it and checks it on every request. Its two primary uses are one-time tokens and refresh token rotation.

One-time tokens (password reset, email verification): after the user clicks the link, the server adds the jti to a Redis denylist with a TTL matching the token's expiry. Any subsequent attempt to use the same token is rejected even though the signature is still valid.

Refresh token rotation: each time a refresh token is used, the server issues a new refresh token (with a new jti) and invalidates the old one. If an attacker uses a stolen refresh token, the legitimate client's next refresh attempt will fail — and the server can detect the reuse as a signal of compromise and revoke the entire token family.

Node.js — one-time token with Redis denylist one-time.js
const jwt    = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const redis  = require('./redis-client');   // ioredis / redis client

const TTL = 3600;  // 1 hour in seconds

// Issue a one-time password-reset token
async function issuePasswordReset(userId) {
  const jti   = uuidv4();
  const token = jwt.sign(
    { sub: userId, jti, purpose: 'password-reset' },
    process.env.JWT_SECRET,
    { algorithm: 'HS256', expiresIn: TTL }
  );
  return token;
}

// Verify and consume the one-time token
async function consumePasswordReset(token) {
  const payload = jwt.verify(token, process.env.JWT_SECRET, {
    algorithms: ['HS256'],
  });

  if (payload.purpose !== 'password-reset') {
    throw new Error('Wrong token purpose');
  }

  const denyKey = `jti-deny:${payload.jti}`;
  const already = await redis.get(denyKey);
  if (already) throw new Error('Token already used');

  // Mark as used — TTL matches the token's own expiry
  const remaining = payload.exp - Math.floor(Date.now() / 1000);
  await redis.setex(denyKey, remaining, '1');

  return payload.sub;  // userId — safe to reset their password now
}

Custom Claims — Namespacing, Payload Size, and PII

Beyond the seven registered claims, you will almost certainly need to carry application-specific data: the user's role, their organisation ID, feature flags, subscription plan. These are custom claims, and three rules govern them.

1. Namespace to avoid collisions

If your claim name might mean something different in another context, prefix it with a URI you control: "https://example.com/role": "admin". No HTTP request is ever made to that URI — it is purely a namespacing convention. Short private claims like org_id are fine when the token is only consumed by your own services and there is no risk of collision.

2. Keep the payload small

The JWT is Base64Url-encoded and sent in the Authorization header of every single request. Large payloads increase bandwidth on every call, inflate cookie size toward the 4 KB browser limit, and bloat server logs. Keep claims to what the service actually needs — identifiers and coarse permissions. Fetch detailed profile data from the database using the sub.

3. Never put PII or secrets in claims

The payload is not encrypted. Anyone who reads the token from a log file, a URL parameter, a browser's network tab, or an XSS-compromised localStorage can decode every claim with a single Base64 decode — no signing key needed. Never store passwords, full names, email addresses beyond what is strictly required, national ID numbers, payment card data, or any value that would trigger a regulatory or legal exposure if leaked.

Quick Reference — All 7 Registered Claims

Claim Full Name Type Validated by default?
jsonwebtoken / PyJWT
What breaks if skipped
exp Expiration Time Number (seconds) Yes / Yes Tokens never expire; a stolen token is valid forever
nbf Not Before Number (seconds) Yes / Yes Pre-issued tokens become valid immediately; time-windowed tokens have no lower bound
iat Issued At Number (seconds) No / No Tokens issued before a key rotation remain valid; max-age enforcement impossible
iss Issuer String No / No Tokens from foreign auth systems accepted; cross-service token replay attacks succeed
sub Subject String No / No No stable user identifier in the token; application must rely on fragile custom claims
aud Audience String or Array No / No A token for mobile-app is accepted by admin-api; audience-scoped tokens provide no protection
jti JWT ID String No / No One-time tokens can be used multiple times; refresh token rotation cannot detect theft

Inspect a Real JWT — Check Every Claim Instantly

Paste any token into the free JWT decoder to see all claims decoded side by side — including the exact expiry timestamp and whether iss, aud, and jti are present. Fully client-side — no server requests, no token logging.

Open Free JWT Decoder →

Related