- Security
- Backend
JWT anatomy, and the mistakes that make one useless
Base64url is not encryption and decoding is not verifying. Token structure, the classic attacks (alg none, RS256/HS256 confusion) and a server-side verification checklist.
JSON Web Tokens are everywhere: application sessions, service-to-service APIs, OAuth 2.0, OpenID Connect. They are also, quite likely, the most misunderstood security primitive in common use — not because they are complicated, but because they look like they offer guarantees they do not. A JWT looks opaque, and it is not. It looks secure by itself, and it is not.
This article starts from the structure, moves to the classic attacks that exploit exactly those misunderstandings, and closes with a server-side verification checklist.
The three parts of a token
A signed JWT is a string split into three sections separated by dots: header, payload and signature, each base64url-encoded. Here is a real token, signed with HMAC-SHA256 and the secret "un-segreto-di-esempio":
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9
.4C5yh-XyLQxIRkCJeVA4M3N-kPKQ9nIbr2_i5-sJRcYThe header declares the signing algorithm and the type. The payload carries the claims — the assertions about the subject. The signature binds the first two segments and the key together.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{
"sub": "1234567890",
"name": "Mario Rossi",
"iat": 1516239022,
"exp": 1516242622
}The signature is computed over the concatenation of the two already-encoded segments joined by a dot — not over the original JSON. That detail matters: verification does not depend on how the JSON gets re-serialized, and any change to the transmitted bytes invalidates the signature.
const signingInput = base64url(header) + "." + base64url(payload);
const signature = hmacSha256(signingInput, secret);
const token = signingInput + "." + base64url(signature);Base64url is not encryption
This is misunderstanding number one, and it has immediate consequences. Base64url is an encoding, not a cipher: it turns bytes into URL-safe characters, and anyone can reverse it without a key. The payload of a signed JWT is readable by anyone who intercepts the token, by the user, by a log file, by a proxy.
echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIn0' | base64 -d
# {"sub":"1234567890","name":"Mario Rossi"}Decoding is not verifying
The second misunderstanding is subtler and more dangerous. Opening a JWT and reading its contents requires nothing: no key, no trust. Verifying it means recomputing the signature with the correct key and comparing it to the one received. These are completely different operations, and several libraries expose them under dangerously similar names.
- decode() — reads the claims without checking anything. Useful for inspecting a token, for debugging, for reading the issuer before you know which key to use. Never for deciding whether a user is authenticated.
- verify() — recomputes the signature, checks expiry and claims, and fails if anything is off. It is the only function whose output may drive an authorization decision.
The classic attacks
The "none" algorithm
The standard defines alg: none for unsigned tokens, intended for contexts where integrity is already guaranteed at another layer. Some historical implementations accepted it on input: rewrite the header, change the payload freely, and leave the third segment empty.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIn0.
↑
no signature: the token ends with a dotMaintained libraries reject this by default today, but the principle generalizes: the defense is not hoping your library behaves, it is refusing to let the token dictate how it gets verified.
Symmetric/asymmetric algorithm confusion
This is the most elegant attack in the family. With RS256 the server signs with a private key and verifies with the matching public key, which is by definition known to everyone. With HS256 the same key both signs and verifies.
If the verification code picks the algorithm by reading it from the token's header, an attacker can switch alg from RS256 to HS256 and sign an arbitrary token using the server's public key as the HMAC secret. The server, following the header, verifies with HMAC using that same public key — and the signature checks out.
// Vulnerable: the algorithm comes from the token, i.e. from the attacker.
jwt.verify(token, key);
// Correct: the algorithm is decided by the server and is not negotiable.
jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});The general rule reaches far beyond JWTs: no externally supplied data should be able to choose how it is validated.
The signature simply never checked
Less spectacular than the others and far more common in practice: middleware calling decode() instead of verify(); a client reading claims to populate the UI and a backend trusting that data; an internal service treating a token forwarded by another service as "already verified". In a microservice architecture that last case is the rule, not the exception — every service that makes authorization decisions must verify for itself.
The claims worth checking
| Claim | Meaning | Why it matters |
|---|---|---|
| exp | Expiry | A token without expiry is a permanent password. |
| nbf | Not valid before | Prevents early use of pre-issued tokens. |
| iat | Issued at | Lets you reject tokens that are too old even if unexpired. |
| iss | Issuer | Stops you accepting tokens minted by another system. |
| aud | Audience | Stops a token valid for one service from opening another. |
| sub | Subject | The stable user identifier. Do not use the email: it changes. |
| jti | Token id | Needed for revocation and replay detection. |
The aud check is the one most often skipped, and it is what separates a multi-service architecture from one where a token stolen from a marginal service opens the main API.
The revocation problem
A JWT is self-contained: the server verifies the signature and trusts the content without consulting a database. That is why JWTs scale well, and it is also their structural limitation. An issued token stays valid until it expires, whatever happens in the meantime — logout, password change, permission revocation, offboarding.
- Short expiries for access tokens, on the order of minutes, with longer-lived refresh tokens that are revocable because they are stored server-side.
- A denylist of revoked jti values, with entries that expire alongside the token: some state comes back, but only for the exceptional cases.
- A per-user "tokens issued before this instant are invalid" timestamp, reset on password change: one read, and it covers the most frequent case.
Where to store the token in a browser
There is no trade-off-free answer, and you should distrust anyone offering one. In localStorage the token is readable by any JavaScript on the page: a single XSS vulnerability, including one in a third-party dependency, exposes it. In a cookie with HttpOnly and Secure, JavaScript cannot see it, but the browser sends it automatically, which reopens the CSRF surface — mitigable with SameSite and anti-CSRF tokens.
The most common compromise today keeps the access token in memory, persisted nowhere, and the refresh token in an HttpOnly SameSite cookie. It costs a refresh round-trip on every page reload, and that is usually the lowest price on the menu.
Verification checklist
- Always verify the signature on the server, with verify() and never decode().
- Pin the list of accepted algorithms yourself; never read it from the token header.
- Check exp, and check iss and aud too whenever the token crosses more than one service.
- Keep expiries short and design a revocation path before you need one.
- Keep confidential data out of the payload: it is readable by anyone.
- Use a maintained library. Hand-rolled cryptography fails in the ways you do not anticipate.
- Serve everything over HTTPS: the signature protects against tampering, not interception.
The author
Francesco Margiotta Casaluci is a backend engineer: he designs and builds microservices in Java and Spring Boot, data pipelines and cloud-native platforms. He writes about what he implements, and he implements the free tools published on this site.
Read the full profile