iKit
Technical · 10 min read ·

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 (2026)

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.
  • iss and aud must 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:

  1. 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.
  2. exp is exclusive. A token with exp = 1715000000 is invalid at 1715000000, not the second after. RFC 7519 is explicit on this.
  3. Clock skew is real. Most production verifiers allow a 30-second leeway on exp and nbf to 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

Related posts