Inside a JWT: A Field-by-Field Guide to Standard Claims (2026)
Every JWT carries the same standard claims — iss, sub, aud, exp, iat, nbf, jti. Here's what each one means, RFC 7519 references, and the bugs they cause.
Inside a JWT: A Field-by-Field Guide to Standard Claims
A production 401 doesn't always mean an invalid signature. Half the time, the token is technically valid — it just claims something the API doesn't accept. The payload between the two dots in a JWT is structured JSON, and the keys it uses are not arbitrary: most are defined by RFC 7519. Knowing each claim, what type it expects, and which mismatch will silently reject your request is the difference between a five-minute fix and a Friday afternoon.
TL;DR
- A JWT payload uses seven registered claims defined in RFC 7519: iss, sub, aud, exp, nbf, iat, jti.
- Time claims (exp, nbf, iat) are Unix timestamps in seconds, not milliseconds — a 1000× off-by-one is the most common bug.
issandaudmust match what your verifier expects, or the token is rejected even with a valid signature.- Custom claims should be namespaced with a URI (Auth0 pattern) to avoid future collisions.
- Decode locally with the iKit JWT Decoder — pasting tokens into a server-side tool leaks them.
The Anatomy of a JWT Payload
The payload is the middle of three base64url-encoded segments. Once decoded, it's a plain JSON object — nothing more exotic than that. Every field in that object is called a claim, and the spec carves claims into three buckets: registered, public, and private. Most of what causes verification to fail lives in the registered set, which is why it pays to know all seven by heart.
From the header to the claims
Before you ever look at the payload, the header tells you what algorithm signed the token. That matters because the first place verification can fail is alg: none — a token that claims to need no signature. Once alg is RS256 or HS256 and the signature is verified, the recipient parses the payload and walks each claim in order. Every step from this point depends on the claims being there and being the right shape.
How RFC 7519 defines a claim
RFC 7519, Section 4 defines a claim as "a piece of information asserted about a subject." The spec then lists seven specific names that have reserved meanings and recommended types. Any other key the issuer wants to add is fine — the registered names just have fixed semantics so that a Firebase token and an Auth0 token can be read by the same client library.
Registered vs public vs private claims
- Registered claims are the seven names defined in Section 4 of the spec (
iss,sub,aud,exp,nbf,iat,jti). They are short on purpose — JWTs travel over HTTP headers, where every byte counts. - Public claims are names registered in the IANA JSON Web Token Claims registry. They have community-agreed semantics but aren't part of the core spec.
- Private claims are anything you and your verifier agreed on. They have no protected namespace, which is why collisions happen. Auth0 and Firebase both work around this by namespacing custom claims with a URI.
The Seven Registered Claims, One by One
Each registered claim is optional, but in practice every modern issuer fills in at least iss, sub, exp, and iat. Here's a quick reference, and then the per-claim breakdown.
| Claim | Name | Type | Notes |
|---|---|---|---|
| iss | Issuer | StringOrURI | Recommended |
| sub | Subject | StringOrURI | Recommended |
| aud | Audience | String or array | Recommended |
| exp | Expiration Time | NumericDate | Recommended |
| nbf | Not Before | NumericDate | Optional |
| iat | Issued At | NumericDate | Recommended |
| jti | JWT ID | String | Optional |
iss — who issued the token
iss is a case-sensitive string that identifies the principal that issued the JWT. Auth0 uses https://YOUR_TENANT.auth0.com/. Firebase uses https://securetoken.google.com/PROJECT_ID. Your verifier should compare iss against an expected value byte-for-byte — including the trailing slash, which Auth0 always includes and which URL.parse will happily strip if you let it.
sub — who the token is about
sub is the subject of the token — almost always the user ID. Firebase uses the user's UID directly. Auth0 prepends a connection name, so auth0|abc123 is a database user and google-oauth2|123... is a federated Google login. Never trust sub until the signature is verified — anything you read from a decoded but unverified token is attacker-controlled.
aud — who can use the token
aud says who the token is for. If your API URL is https://api.example.com and the token's aud says https://billing.example.com, you must reject it. A common failure mode in microservices is forgetting to set the audience explicitly — the token then gets minted with whatever default Auth0 falls back to, and a perfectly valid signature still fails verification at your service.
aud can be a string or an array of strings. Array form looks like:
{
"aud": [
"https://api.example.com",
"https://billing.example.com"
]
}
Some verifiers fail closed on the array form, so test this path explicitly if you issue cross-service tokens.
exp, nbf, iat — the time fields
All three are NumericDate values: the number of seconds since the Unix epoch, as a JSON number. This is the same epoch you see in every server log — for a primer see our Unix timestamp guide, or paste one into the Unix Timestamp Converter to see the human-readable date. Three rules trip almost everyone:
- Seconds, not milliseconds. JavaScript's
Date.now()returns milliseconds. Divide by 1000 before stuffing it into a JWT, or your token will look valid for 50,000 years. expis exclusive. A token withexp = 1715000000is invalid at1715000000, not the second after. RFC 7519 is explicit on this.- Clock skew is real. Most production verifiers allow a 30-second
leewayonexpandnbfto absorb skew between issuer and verifier. If you write your own validator, build that in.
A quick way to read these fields in DevTools:
const [, payload] = token.split(".");
const claims = JSON.parse(atob(payload));
const exp = new Date(claims.exp * 1000);
const iat = new Date(claims.iat * 1000);
console.log({
exp,
iat,
ttlSeconds: claims.exp - claims.iat,
});
jti — the unique ID for replay protection
jti is a string that uniquely identifies a token. It's rarely set on access tokens because they're short-lived; on refresh tokens and password-reset tokens, jti is what lets your server revoke a single token by adding it to a denylist. Pair jti with a UUID v4 generator and you have a clean revocation primitive that doesn't depend on the database PK leaking.
Reading Claims in Code
Decoding is base64url plus JSON parsing, nothing more. Real production code should always verify the signature with a library — but for debugging, a one-liner is often enough.
JavaScript: a 3-line parser
function decodeJwtPayload(token) {
const b64url = token.split(".")[1];
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(b64).split("").map(c =>
"%" + c.charCodeAt(0).toString(16).padStart(2, "0")
).join("")
);
return JSON.parse(json);
}
That decodeURIComponent dance is what makes the function safe for tokens with non-ASCII characters in the payload (name: "François" and the like). Plain atob alone returns binary that breaks on UTF-8.
Python: PyJWT for production
import jwt # pip install PyJWT
payload = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience="https://api.example.com",
issuer="https://tenant.auth0.com/",
)
print(payload)
PyJWT validates exp, nbf, aud, and iss for you when you pass them in. The library will raise on a mismatch — never catch and swallow that exception.
cURL + jq for shell pipelines
echo "$TOKEN" \
| cut -d. -f2 \
| tr '_-' '/+' \
| base64 -d 2>/dev/null \
| jq .
The tr step converts base64url back to standard base64, and the 2>/dev/null swallows the missing-padding warning that base64 emits when the input length isn't a multiple of four. Useful in CI scripts that need to read exp from a token without pulling in a JWT library.
The Bugs These Claims Cause
A working JWT in dev that fails in staging almost always has one of three problems, and all three live in the registered claims.
exp skew between server clocks
The issuer's wall clock is four seconds ahead of your verifier's. A token issued at exp = now + 60 looks valid for 60 seconds at the issuer, 56 at the verifier, and 60 at the user's browser. Set a 30-second leeway in your library config. If you can't — for example, you're verifying in a Lambda with no library options — accept that some valid tokens will fail and surface a retryable error to the client.
aud mismatch in microservices
Service A mints a token with aud: ["https://service-a.example.com"]. Service B receives the same token (because the user is signed in to the platform) and tries to verify it. Service B sees aud doesn't contain https://service-b.example.com and rejects. This is the spec working correctly — the fix is to add your audience to the array when minting, or to issue separate tokens per service.
Missing iss validation
This is a security bug, not a correctness one. If you don't validate iss, a token from any issuer with the same audience can be replayed against your API. In multi-tenant SaaS where every tenant has its own Auth0 connection, this is especially dangerous — the customer's own tokens become a cross-tenant credential. The fix is one line in your verifier config.
Custom Claims Without Breaking the Spec
Adding fields is fine. The spec just asks that you not collide with future registered names or with another vendor's namespace.
Namespacing with URIs
The recommended pattern from RFC 7519 is to put custom claims under a URI you control — even if it doesn't resolve. https://yourdomain.com/role is a valid claim name; if IANA later registers role as a public claim, you're untouched.
Auth0 namespace pattern
Auth0's actions and rules engine requires custom claims to be namespaced — it strips any unnamespaced claim from the issued token. A typical Auth0 payload looks like:
{
"sub": "auth0|abc123",
"iss": "https://tenant.auth0.com/",
"aud": ["https://api.example.com"],
"exp": 1716040000,
"iat": 1716036400,
"https://your-app.com/role": "admin",
"https://your-app.com/org_id": "org_xyz"
}
The namespace prefix is verbose, but it survives the next version of the OpenID spec without renaming work in your codebase.
Firebase custom claims
Firebase takes the opposite approach: custom claims live under top-level firebase.identities and firebase.sign_in_provider namespaces, set via the Admin SDK. They're limited to 1 KB total, which is plenty unless you're trying to embed a permissions matrix in the token — which you shouldn't be doing anyway. Move large authorization data behind an opaque ID and look it up server-side.
Related on iKit
- How to decode an Auth0 or Firebase JWT in your browser — a walkthrough of two real tokens, signature verification, and why decoding locally matters when the payload contains user data.
- Unix timestamp explained: 10-digit numbers in your logs — the time format that
exp,nbf, andiatall use, plus how to convert it without falling into the milliseconds trap.
Related posts
HS256 vs RS256: Which JWT Algorithm Should You Pick (2026)
HS256 vs RS256 is the first decision when issuing a JWT — symmetric speed vs asymmetric key separation. Here's how to pick the right algorithm in 2026.
encodeURIComponent vs encodeURI: When to Use Which (2026)
encodeURIComponent vs encodeURI trips up every JavaScript dev once — here's the actual rule, the characters each protects, and when to pick which in 2026.
Unix Timestamp Explained: 10-Digit Numbers in Your Logs (2026)
A Unix timestamp is the 10-digit number in every log line — here's what it means, why timezones don't apply, and how to convert it without bugs in 2026.