JWT

Designing JWT Claims: What Belongs in a Token and What Doesn't

A practical guide to claim design — the standard claims and how to validate them, why token bloat becomes a 431 outage, PII and privacy in a base64 payload, namespacing custom claims, and versioning a payload you can never fully migrate.

Emilian GheoneaJuly 29, 20269 min read

There is a predictable arc to JWT payloads. The first version has sub and exp. Then someone adds the email so the frontend does not need a profile call. Then the display name, so the header can render immediately. Then the avatar URL. Then the org ID, then the role, then the feature flags, then the list of permissions. Eighteen months later a request fails with a 431 Request Header Fields Too Large and someone spends a day working out why.

Claim design is where JWTs go wrong most often, and unlike a signature bug it fails slowly — as bloat, as staleness, as a privacy problem you did not know you had. This article covers what belongs in a token, what does not, and how to structure the payload so you can change it later.

The one rule

Everything below follows from a single principle:

A claim should be something that was true when the token was issued and will still be true when it expires.

A JWT is a snapshot. It is signed, so it cannot be modified, which also means it cannot be updated. Anything you embed is frozen for the token's lifetime, and every service reading it will act on the frozen value.

Apply that test to the arc above. Is the user's subject ID still true in fifteen minutes? Yes. Their role? Maybe not — an admin can change it. Their feature flags? Almost certainly not; that is the entire point of feature flags. Their avatar URL? It will be stale the moment they upload a new one, and every service will render the old one.

The rule sorts claims cleanly:

Good candidates — the user's stable identifier, when and how they authenticated, the token's own metadata (issuer, audience, expiry, ID), and the tenant context they selected for this session.

Bad candidates — anything mutable by an administrator, anything mutable by the user, anything derived from a system with its own change cadence, and anything large.

The standard claims, and validating them properly

The registered claims are small and each one exists for a reason. Skipping validation on any of them is a vulnerability, not a shortcut.

ClaimMeaningThe check
issIssuerMust exactly equal your expected issuer.
subSubjectThe user ID. Stable, opaque, never an email.
audAudienceMust contain this service.
expExpiryReject if past, with small clock skew allowance.
nbfNot beforeReject if in the future.
iatIssued atUseful for max-age policies.
jtiToken IDEnables denylisting and replay detection.

Three of these are routinely skipped, and each omission is exploitable.

aud is the one that matters most in a multi-service system. Without an audience check, a token minted for your low-privilege public API verifies perfectly at your admin API — same issuer, same key, valid signature. The signature check tells you the token is authentic; the audience check tells you it was meant for you. They are different questions and you need both. This is the same principle underlying token exchange.

iss prevents a whole category of confusion in systems that accept tokens from more than one identity provider. If you verify against a key set without checking who issued the token, any issuer whose keys you trust for any purpose can produce a token for any purpose.

sub must be opaque and immutable. Using an email address as the subject is the mistake that surfaces the day a user changes their email — every token, every log line, and every foreign key that referenced them now points at a stranger, potentially the person who later claims that address. Use a UUID or another internal identifier that never changes.

A note on exp: allow a small clock skew, typically 30 to 60 seconds. Server clocks drift, and a token rejected because the verifier's clock is two seconds ahead of the issuer's produces intermittent failures that are miserable to diagnose. Allow skew on exp and nbf; do not allow so much that it meaningfully extends the token's life.

Token bloat is an outage waiting to happen

A JWT typically travels in an Authorization header, and headers have limits you do not control:

  • nginx defaults to 8KB total for request headers.
  • Many CDNs and load balancers cap individual headers at 8–16KB.
  • AWS ALB allows 16KB; API Gateway is stricter.
  • Some corporate proxies are far more conservative.

A base64-encoded payload is roughly 33% larger than its JSON. A permissions array with 200 entries, or a set of feature flags, or a group list from an enterprise directory, gets there faster than people expect. Microsoft's guidance on group claims exists precisely because tokens for users in many groups became too large to use.

The failure mode is nasty. It is not a clean error — it is a 431 or 400 from an intermediary, before your application code runs, so there is nothing in your logs. It hits only users with a lot of something, which means it works for the whole team and fails for your biggest customer. And it appears gradually, as one user crosses the threshold, then a few more.

The fixes, in order of preference:

Do not put the list in the token. Put a reference. "perm_set": "ps_9f2a" and let services resolve it from a cache. One small lookup, and the permissions are now current rather than a snapshot — solving staleness and bloat with the same change.

Send a scope summary rather than an enumeration. "scope": "contacts:read orders:write" is compact. A full permission matrix is not.

Emit a claim that says "too many to list." This is what Microsoft's hasgroups overage indicator does — it tells the consumer to go fetch the list rather than silently truncating it. Truncation is the worst option, because it produces wrong authorization decisions with no error.

Set a hard budget and enforce it in CI. Assert that a token generated for a synthetic worst-case user stays under, say, 4KB. This is a five-line test that prevents a production incident.

Base64 is not encryption

Anyone holding the token can read every claim. This bears repeating because it is forgotten constantly, and it has real consequences.

A JWT ends up in more places than you think: browser storage, browser history if it ever appears in a URL, Referer headers, server access logs, APM traces, error reports with full request context, CI logs, and the support ticket where a user pasted their network tab. Every one of those is now a location containing whatever you put in the payload.

So: no PII in claims, unless you have decided it is acceptable in all of those places. Not full names, not email addresses, not phone numbers, not physical addresses, not anything from a special category — health, biometrics, and so on. Under GDPR, an email address in a token stored in localStorage and echoed into your log aggregator is personal data processing in every one of those systems, and it is the kind of thing that turns a routine audit into a project.

The workaround for the original motivation — "the frontend needs the name to render the header" — is a /me endpoint. One request, cached, always current, and it keeps identity data out of every log line. As a bonus it fixes the staleness problem: a name in a token is wrong until the token expires; a name from an endpoint is right immediately.

If you genuinely need confidential claims, JWE encrypts the payload. It is more complexity and more key management than most systems need, and the honest answer for nearly everyone is: do not put the secret in the token.

Namespacing custom claims

The registered claim names are a public registry, and unregistered names risk colliding with a future standard or with another issuer in your system. The convention that avoids this is a URI namespace:

{
  "sub": "user_123",
  "https://embedauth.com/org": "org_acme",
  "https://embedauth.com/role": "admin"
}

Verbose, and it is what OIDC and most identity providers do, because it is unambiguous. If you control both ends and accept the small risk, a short consistent prefix is a reasonable compromise:

{ "sub": "user_123", "ea_org": "org_acme", "ea_role": "admin" }

What you should not do is claim a bare generic name like role, permissions, or tenant in a system that will ever consume tokens from more than one issuer. The day two issuers disagree about what role means is the day you get a very confusing authorization bug.

Versioning claims

You will need to change the payload, and you cannot migrate tokens that are already in the wild. Plan for it.

Add a version claim from the start:

{ "sub": "user_123", "ver": 2, "ea_org": "org_acme" }

Then the migration is mechanical: verifiers accept both versions, you switch issuance to v2, you wait out the maximum token lifetime, and you drop v1 support. This is exactly the same overlap-window shape as key rotation, for the same reason — old tokens keep arriving after you stop making them.

Two rules make this survivable. Additive changes are safe; removals and redefinitions are not. Adding a claim that old verifiers ignore is fine. Changing what an existing claim means is not, and it will not fail loudly — it will silently produce wrong decisions. Give the new meaning a new name.

Verifiers must ignore unknown claims. A verifier that rejects tokens containing claims it does not recognize makes every future addition a breaking change requiring lockstep deploys. Be liberal in what you accept here.

The claims that are actually worth adding

Beyond the registered set, a small number of custom claims genuinely earn their place:

Tenant context — which organization this session is acting in. Necessary in multi-tenant systems, and it satisfies the stability rule because switching tenants should issue a new token anyway. Remember that the signed claim tells you which tenant was selected, not that the user is permitted there — membership still gets verified per request.

Authentication method and timeamr and auth_time, both registered by OIDC. These are what let a service require a stronger factor for a sensitive operation without a database round trip, and they are genuinely immutable facts about a past event. This is the cleanest example of a claim that fits the rule perfectly.

A session identifiersid, linking the token to a server-side session record so revocation and session listing work. Small, and it is the hook that makes the hybrid model in JWTs vs opaque tokens work.

A token IDjti, for denylisting and replay detection.

Notice what these have in common: they are all small, all stable, and all facts about the authentication event rather than about the user's current state. That is the pattern.

Key takeaways

  • A claim must remain true for the token's entire lifetime. Everything else is a bug with a delay.
  • Validate iss, aud, and expaud especially, or an authentic token from one context works in another.
  • sub is opaque and immutable. Never an email address.
  • Budget the token size and test it, or bloat becomes a 431 that only affects your largest customer.
  • Base64 is not encryption. No PII. Use a /me endpoint for profile data — it is fresher anyway.
  • Namespace custom claims and version the payload from day one; migrate with an overlap window.
  • The claims worth adding are small facts about the authentication event, not about mutable user state.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.