Fastway
Back to blog

What's Inside a JWT — And What You Should Never Put There

Rodrigo Krohling
·7 min read

Take any JWT your application issues and paste it into a decoder. The payload appears instantly. No password, no key, no request to your server.

That is not a weakness in the decoder. It is the format working as designed, and almost every JWT incident traces back to somebody assuming otherwise.

Three parts, two dots

A JWT is a single string with two dots in it:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Split on the dots and you get the header, the payload, and the signature. The first two are Base64url-encoded JSON — reversible by anyone, no key required. Decode the first:

{ "alg": "HS256", "typ": "JWT" }

And the second:

{ "sub": "1234", "role": "admin" }

The third part is the signature: the first two parts, joined by a dot, run through the algorithm named in the header using a secret only the server holds.

So the guarantee a JWT provides is integrity and authenticity, not confidentiality. It proves the payload has not been altered since the server signed it. It does nothing at all to hide the payload.

Base64url, not Base64

The encoding is the URL-safe variant: + becomes -, / becomes _, and the trailing = padding is stripped. This matters when you are debugging by hand, because pasting a JWT segment into a standard Base64 decoder can fail until you put the padding back.

The rule: append = until the length is a multiple of four. A segment of length 22 needs two, length 23 needs one, length 24 needs none.

What never belongs in the payload

Since anyone holding the token can read every claim, the list is short and absolute.

Anything you would not print in a log. Passwords, obviously. But also API keys, internal database IDs you would rather not expose, licence numbers, and national identifiers. If it would be a problem in a support ticket screenshot, it is a problem here.

Personal data you do not need on every request. Email addresses and full names are the routine offenders. They end up in browser storage, in server access logs, in error-reporting payloads, and in any proxy that logs headers. Under the GDPR and the LGPD that is processing you now have to justify, for a convenience you probably did not need.

Anything that changes more often than the token. A JWT is a snapshot taken at sign-in. If you put a permission set in it and the user is demoted, the token keeps asserting the old permission until it expires. This is the fundamental trade of stateless tokens: you avoid a database lookup per request and you accept a window of staleness. Design the expiry around how long that window can be.

The four claims worth knowing

The spec registers several claim names. These are the ones that carry weight:

  • exp — expiry. Seconds since the Unix epoch.
  • iat — issued at. Same units.
  • nbf — not before. The token is invalid until this moment.
  • sub — the subject, i.e. who the token is about.

Those units are the source of a bug that hides completely in testing. JavaScript's Date.now() returns milliseconds. The claims are in seconds. Compare them directly and every token looks valid until roughly the year 55000:

// Wrong — compares milliseconds to seconds.
if (payload.exp > Date.now()) { /* always true */ }

// Right.
if (payload.exp > Math.floor(Date.now() / 1000)) { /* ... */ }

Nothing fails in development, because the tokens have not expired yet. It surfaces in production the first time an expired token is accepted. If you are eyeballing a timestamp and want to know what it means, a timestamp converter is quicker than doing the arithmetic.

The alg: none problem

The header declares which algorithm signed the token. A verifier that reads that field to decide how to check the signature is trusting the attacker's input to tell it how to validate the attacker's input.

The classic exploit sets "alg": "none", strips the signature entirely, and sends a payload claiming whatever it likes. A library that honours the header sees "no algorithm", performs no verification, and accepts it.

A related variant swaps RS256 for HS256. In RSA the server verifies with a public key; in HMAC it verifies with a shared secret. If the verifier switches based on the header, an attacker can sign a token using the public key as the HMAC secret — public, so they have it — and the server validates it.

Both are fixed the same way: pin the expected algorithm server-side and ignore what the token claims. Every mature library supports this; it is usually an argument you have to pass rather than a default you get.

Decoding is not verifying

This deserves its own heading because it is the mistake that turns a misunderstanding into a vulnerability.

Decoding shows you the contents. It says nothing about whether the signature is valid. A tampered token decodes exactly as cleanly as a genuine one — change "role": "user" to "role": "admin", re-encode, and the decoder will show you an admin token.

Only the holder of the signing key can tell them apart. Which means:

  • Verification belongs on the server, always.
  • A client may decode a token to read a display name or an expiry for UI purposes. It must never make an authorisation decision from it.
  • Any endpoint that trusts a claim without verifying the signature is unauthenticated, regardless of how much the request looks authenticated.

A short design checklist

  • Put the smallest thing that identifies the subject in sub, and nothing you would mind leaking.
  • Set a short exp. Minutes for access tokens, and a separate refresh mechanism for longevity.
  • Pin the algorithm in the verifier. Reject none explicitly.
  • Compare exp in seconds.
  • If you genuinely need the payload hidden, you want JWE — encrypted tokens — not a signed one with the sensitive parts obfuscated. Base64 is not obfuscation, and it never was.

A JWT is a signed postcard. Perfectly good for what it does; just do not write anything on it you would not want the postman to read.