JWT Tokens: What They Actually Prove (and What They Never Did)

A JWT is three chunks of base64, and two of them are readable by anyone. I explain what the signature proves, why decoding is not verifying, and expiry.

Three chunks of text separated by dots

A JSON Web Token looks like gibberish, but its anatomy is fixed and simple: three runs of characters separated by two dots. The first chunk is the header, the second is the payload, the third is the signature. The header and payload are just JSON objects encoded with base64url, which is an encoding, not encryption. Anyone who holds the token can read both chunks with zero secrets and zero effort.

That sentence is the single most important fact about JWTs, so let me say it plainly: the contents of a JWT are not hidden. If your token contains an email address, a user ID, and a role, then everyone who ever sees that token, every log file it lands in, every browser extension that reads your requests, can read those values. RFC 7519 never promised confidentiality from base64 alone. Anything secret must not be in the payload.

What lives in each part

The header typically declares two things: the token type and the signing algorithm, for example HS256, which is HMAC with SHA-256, or RS256, which is an RSA signature. The payload carries the claims, which are plain JSON fields. RFC 7519 registers a handful of standard ones with defined meanings, and applications add their own on top.

The registered claims worth knowing by heart: iss says who issued the token, sub says who it is about, aud says who it is for, exp says when it expires, nbf says when it starts being valid, and iat says when it was issued. The three time claims are NumericDate values, which the RFC defines as seconds since the Unix epoch, the same counting scheme I unpacked in my Unix timestamps explainer. So an exp of 1786413600 means 02:00 UTC on August 11, 2026, and nothing about any local clock.

The signature: the only part doing real work

The third chunk is where the actual cryptography lives. To create it, the issuer takes the encoded header, a dot, and the encoded payload, then signs that exact string with a key. With HS256 that key is a shared secret and the signature is an HMAC, conceptually a keyed cousin of the digests you can produce with a hash generator. With RS256 the issuer signs with a private key and anyone can verify with the matching public key.

What the signature proves is narrow and precise: this exact header and payload were produced by someone holding the key, and not a single character has changed since. Flip one letter of the payload and verification fails. What the signature does not prove is just as important: not that the claims are true, not that the token was sent by its rightful owner, and not that it has not been stolen. A leaked JWT works perfectly for whoever holds it until it expires, which is why short expiry windows exist.

Decoding is not verifying, and the bug that taught me

Decoding a JWT means base64-decoding two chunks of public text. Verifying means recomputing the signature with the key and comparing. These are different universes, and confusing them is the classic JWT security hole. My own entry in that hall of shame: years ago I shipped an internal admin panel that read the role claim from the decoded payload and showed the admin menu if it said admin. The backend did verify properly, so no real breach was possible, but the frontend was trusting unverified text, and a colleague demonstrated the point by handcrafting a token that lit up every admin button in my UI. Embarrassing, instructive, permanent.

The rule that fell out of it: only the party holding the key gets an opinion. A server verifies signatures and may then trust claims. A browser, a script, or a curious human can decode for debugging, and that is genuinely useful, but nothing security-relevant may ever branch on an unverified claim. The RFC is blunt about the same idea in its security considerations, and history agrees: the infamous alg none attacks worked precisely against implementations that skipped real verification while acting as if they had performed it.

Expiry, clocks, and why tokens die young

The exp claim is a hard deadline in Unix seconds, and verifiers must reject a token whose deadline has passed. Because two machines never agree perfectly about the time, implementations allow a small leeway, conventionally a minute or two, so a token does not die in transit over a 30-second clock skew. If your API mysteriously rejects fresh tokens, check the issuing server's clock before blaming the library; I have watched a machine running four minutes fast mint tokens that appeared already expired to everyone else.

Short lifetimes are the standard defense given that stolen tokens cannot be distinguished from legitimate ones. A common arrangement pairs an access token measured in minutes with a longer-lived refresh token stored more carefully, so a leaked access token has a blast radius of minutes rather than months. None of this replaces the ordinary hygiene around credentials: the secrets signing your tokens deserve the same strength and storage discipline as any password, which is exactly what a password generator and a manager are for.

Never paste production tokens into random websites

Here is the uncomfortable part of the JWT debugging ritual. A token is a bearer credential: whoever holds it, is you, as far as the API is concerned. Pasting a live production token into a random web tool means transmitting a working credential to a stranger's server, where it may be logged, cached, or worse. The tool does not need your password; the token is the password for its lifetime.

The safe version of the ritual exists, and it is worth being picky about. A JWT decoder that runs entirely client-side does the base64 decoding in your own browser with JavaScript, and the token never leaves your machine; that is precisely why I built ours to work offline. Even then, my personal rules stack up: prefer decoding tokens from development environments, treat any pasted production token as burned and rotate it if the tool's behavior is unknown, and remember that the payload you are inspecting may itself contain personal data that deserves care.

The checklist I hold JWTs to now

Everything above compresses into a handful of habits. None of them are exotic; all of them exist because someone, often me, learned the hard way.

  • Never put secrets or sensitive personal data in a payload; it is readable by design
  • Verify signatures server-side on every request; decode-only paths make no decisions
  • Keep access tokens short-lived and check exp with modest clock leeway
  • Reject tokens whose alg is not on your explicit allowlist
  • Decode tokens only in client-side tools, and treat any token pasted into an unknown site as compromised
  • Guard signing secrets like passwords, because that is what they are

Questions people ask

Are JWTs encrypted?

The common signed kind is not. Header and payload are base64url-encoded JSON that anyone can read. An encrypted variant called JWE exists but is far rarer; unless you know you are using it, assume your token contents are public.

What does the exp claim actually contain?

A NumericDate: the expiry instant as seconds since the Unix epoch, per RFC 7519. An exp of 1786413600 decodes to 02:00 UTC on August 11, 2026. Verifiers reject the token after that instant, usually allowing a minute or so of clock skew.

Is it safe to paste a JWT into an online decoder?

Only if the decoder runs fully client-side so the token never leaves your browser, and even then prefer non-production tokens. A JWT is a bearer credential: any server that receives it can use it until it expires.

Can I revoke a JWT before it expires?

Not by default; statelessness is the point and the price. Revocation requires server-side help such as a denylist or token versioning, which is why short expiries plus refresh tokens are the standard compromise.

What is the difference between HS256 and RS256?

HS256 signs and verifies with one shared secret, so every verifier could also mint tokens. RS256 signs with a private key and verifies with a public one, letting many services verify without being able to forge, which suits distributed systems better.

Read next

All articles