A support engineer pastes an access token into a decoder, sees `"role": "admin"`, and concludes the user is an admin. The claim is right there in readable JSON, so the inference feels safe. It is not: the same JSON is readable because it was never hidden, and it is trustworthy only if something checked the third section of the token, which the decoder was never given the key to do.
Three Sections, Two of Them Public
A signed JWT is three base64url strings joined by dots, described by RFC 7515 as the compact serialisation of a JWS.
The first section is the header, a small JSON object naming the algorithm the signature was made with and, usually, the media type. The second is the payload: the claims, again as plain JSON. The third is the signature, which is raw bytes rather than text, and is the only part that is not human-readable after decoding.
Base64url is an encoding, not a cipher. It exists because the token has to survive being put in a URL, a cookie or an HTTP header without any character needing escaping, so RFC 4648 section 5 swaps + and / for - and _ and drops the padding. Reversing it takes no key and no permission, which is why the first two sections of every JWT you have ever issued should be treated as published.
The practical consequence is short: never put anything in a payload that the holder of the token should not see. Internal user ids, feature flags and roles are fine. Email addresses of other people, internal hostnames, anything with a compliance boundary around it, are not.
The Claims RFC 7519 Actually Defines
Everything else in a payload is the issuer's own invention, however official the name looks.
| Claim | Meaning | What a receiver should do with it |
|---|---|---|
| iss | Issuer | Match against the one issuer you expect, not a list of anything plausible |
| sub | Subject | Treat as the user id, unique only within that issuer |
| aud | Audience | Reject the token if your own identifier is not in it |
| exp | Expires at | Reject at or after this time, allowing a small clock skew |
| nbf | Not before | Reject until this time |
| iat | Issued at | Useful for age limits; not an expiry on its own |
| jti | JWT id | Store it if you need to stop a token being replayed |
Decoding answers what the token says, not whether it is true
Decoding is base64 in reverse and needs nothing from you. Verifying recomputes the signature over the header and payload with a key, and compares. Only the second step tells you the claims are the ones the issuer wrote. Every finding that begins "we read the user id from the token" and ends badly sits in the gap between those two sentences.
What the Signature Settles, and What It Does Not
The signature covers exactly one string: the header and payload sections, still encoded, joined by a dot. Change a single character in either and the recomputed signature stops matching.
That gives you integrity and origin. Nobody without the key can alter the claims, and nobody without the key can produce a token that verifies. Both properties are worth having and both are narrow.
What the signature does not give you is any statement about time, audience or revocation. A signature stays valid forever, because mathematics has no opinion about the clock. A token that expired last March still has a perfect signature; it is the exp check, done separately and after verification, that makes it useless. The same goes for aud: a token minted correctly for a different service in your estate is fully authentic and still must be refused.
This is why RFC 8725, the best-practices document published in 2020 for exactly these mistakes, treats signature verification and claims validation as two obligations rather than one.
How Verification Gets Skipped by Accident
Almost nobody decides to trust an unverified token. It happens through defaults and convenience functions.
Calling the decode helper instead of the verify one
Most libraries ship both. In the Node `jsonwebtoken` package they are `decode()` and `verify()`, and the first takes no key, so it always works and never complains. It is the natural thing to reach for while debugging and the easy thing to leave in.
The "none" algorithm
RFC 7515 defines an unsecured JWS whose alg is "none" and whose signature is empty. Tim McLean's March 2015 disclosure showed several libraries would accept one when the caller had asked for a real algorithm, so an attacker could strip the signature and rewrite the payload. Modern versions refuse, but the shape of the bug survives anywhere the token gets to nominate its own treatment.
Letting the header pick the algorithm
The same disclosure covered the more elegant version: take a service that verifies RS256, hand it a token whose header says HS256, and signed with the RSA public key as if it were an HMAC secret. The public key is public, so the attacker has it. The fix is in RFC 8725 section 3.1 and it is blunt: the verifier decides the algorithm from its own configuration, and a token naming anything else is rejected before its signature is even considered.
Fetching the key from wherever the token points
The `jku` and `x5u` header parameters carry URLs. A verifier that fetches them without an allowlist will happily fetch the attacker's key set and confirm the attacker's signature. The same applies to `kid` when it is interpolated into a filesystem path or a SQL query.
Verifying, then never checking exp or aud
The library returned a payload without throwing, so the token is good. It is authentic; whether it is current, and whether it was meant for this service at all, are separate questions that nothing asked.
Read a token and check its signature
Claims in plain English, HMAC and public key verification, all in the browser.
The Timestamps Are Seconds, and This Catches Everyone
RFC 7519 section 2 defines exp, nbf and iat as a NumericDate: the number of seconds since 1970-01-01 UTC, ignoring leap seconds.
JavaScript's Date.now() returns milliseconds. Writing it straight into exp produces a number a thousand times too large, which places the expiry roughly fifty thousand years out. The token is well formed, every library accepts it, and it never expires. Nothing in the system reports an error, because from its point of view nothing is wrong.
The reverse mistake is quieter still. Reading a NumericDate and passing it to new Date() without multiplying by 1000 gives you a moment in January 1970, so the token looks long expired and the failure gets blamed on clock configuration.
A decoder that renders these numbers as real dates turns both bugs into something you can see in a second, which is most of the reason to look at a token in a tool rather than a log line.
Tokens Grow, and Headers Have Limits
Because the payload is JSON and base64 adds a third on top, a JWT is considerably larger than the session id it usually replaces. A token carrying a handful of registered claims lands around 300 to 500 characters. Add a permissions array, a tenant, a set of feature flags and a profile picture URL and a few kilobytes is easy.
That matters because the token normally travels in the Authorization header on every single request. nginx allocates 8 KB for large request headers by default through large_client_header_buffers, and various proxies and API gateways sit below that. The failure is a 431 or a 400 from an intermediary rather than from your application, which makes it awkward to diagnose from inside the code.
If a token is heading past a kilobyte, the usual answer is to stop shipping the whole profile in it: keep identity and coarse authorisation in the token, and look the rest up by sub when a request needs it.
Frequently Asked Questions
Related Tools
Keep Reading
Signing a JWT: Which Algorithm, and How Long the Key Has to Be
HS256 hands every verifier the power to forge. RS256 does not. And the 32-byte minimum in RFC 7518 is the line between a signature and a formality.
Base64 Is Not Encryption: What It Does and Why It Exists
Three bytes become four characters, which is where the 33 percent size increase and the trailing equals signs come from. And why it hides nothing.
JSON Errors Explained: Trailing Commas, NaN and Other Rejections
Why valid-looking JSON fails to parse, how to read a parser's error position, and the number precision bug that silently corrupts large IDs.
SHA-256 Explained: What Hashes Guarantee and What They Do Not
Three properties make a hash cryptographic, and each one supports a different use. Why SHA-256 is the wrong tool for storing passwords.