JWT Claims Explained with Real Examples
JWT claims explained clearly: what iss, sub, aud, exp, nbf, iat, and jti mean, how private claims work, and how to read any token in seconds.
You get a JWT back from an auth server. You base64-decode the payload and stare at a blob of JSON: iss, sub, aud, exp, iat, jti. Some of these you vaguely recognise. Others youâve been ignoring and hoping they donât matter.
They matter. Misunderstand exp or aud and youâll spend an afternoon chasing an auth bug that has a two-line fix. This is what each claim means, with real values and the specific mistakes that come up in production.
TL;DR: Paste any JWT into GoGood.dev JWT Decoder â it decodes the payload instantly and shows a human-readable description for every registered claim, including
expconverted to a real timestamp.
What are JWT claims?
A JWT (JSON Web Token) is made of three parts: a header, a payload, and a signature. The payload is a JSON object containing claims â key/value pairs that assert facts about a user, session, or request.
{
"iss": "https://auth.example.com",
"sub": "usr_7k2m9x1p",
"aud": "api.example.com",
"exp": 1777000000,
"iat": 1776996400,
"email": "alice@example.com",
"role": "admin"
}
The JWT spec (RFC 7519) defines three categories of claims:
- Registered claims â a standard set with fixed names and meanings (
iss,sub,aud,exp,nbf,iat,jti) - Public claims â custom claims published in the IANA JWT registry to avoid naming collisions
- Private claims â custom claims agreed upon between the token issuer and consumer (
email,role,permissions)
Most tokens youâll encounter use a mix of registered and private claims.
The 7 registered JWT claims explained
iss â Issuer
Who created and signed the token. Typically a URL identifying the auth server.
"iss": "https://auth.example.com"
Your API should validate that iss matches the expected auth server. If you accept tokens from multiple issuers (e.g. different environments), maintain an allowlist and reject anything else.
sub â Subject
The principal the token is about â almost always a user ID. This is the canonical identifier your application should use to look up the user in your database.
"sub": "usr_7k2m9x1p"
sub should be stable and unique. Never use email as a substitute for sub â email addresses change; user IDs shouldnât.
aud â Audience
Who the token is intended for. This is the identifier of the service that should accept the token.
"aud": "api.example.com"
This claim causes more auth bugs than any other. If your API doesnât validate aud, it will accept tokens issued for a completely different service. A token issued for mobile.example.com should be rejected by api.example.com even if the signature is valid.
aud can be a single string or an array of strings when a token is intended for multiple services:
"aud": ["api.example.com", "admin.example.com"]
exp â Expiration Time
Unix timestamp (seconds since epoch) after which the token must be rejected.
"exp": 1777000000
The most common mistake: treating this as milliseconds. JavaScriptâs Date.now() returns milliseconds; JWT timestamps are in seconds. If you compare exp against Date.now() without dividing by 1000, every token will appear valid for decades.
// â Wrong â compares seconds against milliseconds
if (payload.exp < Date.now()) { throw new Error('expired'); }
// â
Correct
if (payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('expired'); }
A small clock skew allowance (30â60 seconds) is standard practice to handle minor time differences between services.
nbf â Not Before
Unix timestamp before which the token must not be accepted. The mirror image of exp.
"nbf": 1776996400
Less commonly used, but useful for pre-issuing tokens that become valid at a specific time â for example, a scheduled access grant or a time-limited download link.
iat â Issued At
Unix timestamp when the token was created. Useful for determining token age and for implementing rotation policies.
"iat": 1776996400
You can use iat to enforce maximum token lifetime independently of exp:
const maxAgeSeconds = 8 * 60 * 60; // 8 hours
if (Math.floor(Date.now() / 1000) - payload.iat > maxAgeSeconds) {
throw new Error('token too old');
}
jti â JWT ID
A unique identifier for this specific token. Used to prevent token replay attacks â once a token has been used, store its jti in a cache (with TTL matching exp) and reject any subsequent request with the same jti.
"jti": "tok_a1b2c3d4e5"
jti is optional and most stateless auth flows donât use it. Itâs most relevant for one-time-use tokens (password reset links, email verification, single-use API keys).
Private and public claims in practice
Every real token youâll encounter has both registered claims and private claims â the custom fields your auth server and API agreed on. Hereâs what that looks like:
{
"iss": "https://auth.example.com",
"sub": "usr_7k2m9x1p",
"exp": 1777000000,
"iat": 1776996400,
"email": "alice@example.com",
"role": "admin",
"permissions": ["read", "write", "delete"],
"org_id": "org_abc123"
}
email, role, permissions, and org_id are private claims â theyâre not in the JWT spec, theyâre just agreed on between the auth server and the API.
Three things that bite developers who donât think about this:
Keep sensitive data out of the payload. JWTs are base64-encoded, not encrypted. Anyone who gets the token can read the payload without knowing the signing secret. Never put passwords, PII beyond whatâs necessary, or anything you wouldnât put in a URL query string.
Keep the payload small. Every request that uses a JWT sends the full token. Bloating the payload with large objects (full user profiles, permission trees) slows down every request and can cause issues with HTTP header size limits. Include only what the API needs to authorise the request.
Avoid mutable data in claims. If a userâs role changes after a token is issued, the token still contains the old role until it expires. Design your auth flow with this in mind â either use short-lived tokens or have a way to revoke or invalidate tokens when critical permissions change.
How to read JWT claims quickly
Paste it into GoGood.dev JWT Decoder â it shows every registered claim with a human-readable description, and converts exp, nbf, and iat from Unix timestamps to actual dates. No base64 arithmetic required.
For the command line, jq and cut get you there in one line:
# Decode the payload (no signature verification)
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfMTIzIiwiZXhwIjoxNzc3MDAwMDAwfQ.sig" \
| cut -d. -f2 \
| base64 --decode 2>/dev/null \
| jq .
In Node.js, decode without verifying (useful for debugging â never for actual auth):
const token = 'eyJhbGci...';
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString()
);
console.log(payload);
console.log('expires:', new Date(payload.exp * 1000).toISOString());
Common problems with JWT claims
exp validation passing when it shouldnât
Almost always the seconds-vs-milliseconds mistake. Double-check your comparison: payload.exp < Math.floor(Date.now() / 1000).
aud mismatch errors
Your JWT library is doing its job â the tokenâs aud doesnât match what your API expects. Either the token was issued for the wrong audience, or your APIâs expected audience string is configured incorrectly. Check both the token payload and your libraryâs configuration.
sub contains an email instead of an ID
Some auth providers use sub for a user ID, others use email. If yours uses an opaque ID (like usr_7k2m9x1p or a UUID), donât try to extract an email from it â look at the private claims. If the provider uses email as sub, be aware that it changes if the user updates their email.
Missing jti when you need idempotency
One-time-use tokens (password reset, email verify) need jti and a server-side token store to prevent replay. Relying on exp alone isnât enough â a reset link can be reused within its validity window if you donât track jti.
FAQ
Whatâs the difference between JWT claims and JWT payload?
Same thing. The payload is the set of claims â âclaimsâ is just the specâs word for the key/value pairs in the payload JSON. Youâll see both terms used interchangeably.
Are JWT claims encrypted?
No â base64url-encoded, not encrypted. Anyone who has the token can read the claims. atob() in the browser, done. The signature proves the token wasnât tampered with, but it doesnât protect confidentiality. If you need the payload encrypted, look at JWE (JSON Web Encryption). Most JWTs youâll encounter are JWS (signed, not encrypted).
Can I add any custom claims I want?
Yes, with one rule: donât collide with registered names (iss, sub, aud, exp, nbf, iat, jti) unless you mean what those names mean. Beyond that, any valid JSON key/value pair works. Convention is to namespace private claims with your domain â https://myapp.com/role instead of just role â to avoid collisions with public claim registries.
How long should JWT expiry (exp) be?
Access tokens: 15 minutes to 1 hour is the standard range. Refresh tokens: days to weeks, but rotate them on use. The logic is simple â a short exp limits the blast radius of a stolen token. If your users complain about being logged out, the answer is better refresh token UX, not a longer access token lifetime.
Why does my JWT decoder show a different time than expected for exp?
The decoder is treating the exp value as milliseconds instead of seconds â a classic mistake. JWT timestamps are always Unix seconds; Date.now() in JavaScript returns milliseconds. A decoder that skips the á1000 step will show a date 1000x further in the future than it should. GoGood.dev JWT Decoder shows exp as both a human-readable timestamp and a relative time (âin 41 daysâ) so this is immediately obvious.
Most auth bugs have a JWT at their center. Once you can read a tokenâs payload without guessing â aud mismatch, exp in the past, a missing jti in a one-time flow â the problem is usually obvious. The hard part isnât the fix; itâs knowing what to look at.
For related reading: How to Check If a JWT Token Is Expired (Without a Library) walks through the expiry check in detail, and Reading JWT Payloads in the Browser â No Code Needed covers the quickest ways to inspect a token during development.