Guide
What's inside a JWT — and what it doesn't protect
A JSON Web Token looks opaque, and that appearance does a lot of damage. It is not encrypted, it is not a secret, and pasting one into a decoder is not a security hole — the payload was always readable. What the signature protects is that nobody changed it.
Three segments, two dots
The JWT you see in an Authorization: Bearer header is almost always a JWS — a signed token. It is three Base64URL-encoded segments joined by .:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3NjcyMjU2MDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
└────────── header ──────────┘ └──────────── payload ────────────┘ └────────── signature ──────────┘
The header and payload are JSON. Base64URL is used rather than standard Base64 precisely because tokens travel in URLs and headers, so the alphabet avoids + and / and the padding is stripped. Decoding either segment is a one-line operation available in every language — there is no key involved and no secret revealed by doing it.
The signature is the part that is not JSON. It is raw bytes, Base64URL-encoded, computed over the exact ASCII string header.payload — the encoded forms, joined by the dot, not the decoded JSON. That detail matters: re-serialising the JSON and re-signing will produce a different string, because JSON serialisation is not canonical.
The header
Short, and mostly about how to verify the thing:
{
"alg": "HS256",
"typ": "JWT",
"kid": "2026-05-key-a"
}
alg— the signing algorithm.HS256is HMAC with SHA-256 and a shared secret.RS256is RSA with SHA-256,ES256is ECDSA on P-256 — both public-key, so the issuer signs with a private key and anyone can verify with the public one.typ— almost alwaysJWT. Informational.kid— key ID, letting the issuer rotate keys and the verifier pick the right one out of a JWKS endpoint.
The header is attacker-controlled input. It tells you what the sender claims the algorithm is, which is not the same as what you should accept. More on that below.
The payload, and the claims that mean something
The payload is an arbitrary JSON object, but RFC 7519 reserves a handful of names with defined meanings. Libraries check some of these for you and ignore the rest, so it is worth knowing which is which.
iss— issuer. Who minted the token.sub— subject. Usually the user ID.aud— audience. Which service the token is meant for. A token issued for your billing API should not be accepted by your admin API.exp— expiry. After this instant, reject.nbf— not before. Before this instant, reject.iat— issued at. Useful for "force re-auth if older than N".jti— token ID, so a specific token can be blacklisted.
exp, nbf, and iat are NumericDate values: seconds since the Unix epoch, not milliseconds. This is the single most common bug when hand-minting tokens in JavaScript, where Date.now() gives milliseconds. A token stamped with milliseconds has an expiry roughly 50,000 years out, and every expiry check silently passes.
// wrong — expires in the year 57000
exp: Date.now() + 3600000
// right
exp: Math.floor(Date.now() / 1000) + 3600
Everything else in the payload is yours: roles, tenant IDs, feature flags, an email address. All of it is public. Anyone holding the token — including the user, including anything that logged the header — can read it.
Decoding is not verifying
These are two entirely separate operations and conflating them is where most JWT vulnerabilities live.
Decoding splits on dots and Base64URL-decodes the first two segments. It needs no key, proves nothing, and is what a decoder tool does. The result is untrusted attacker-supplied JSON.
Verifying recomputes the signature over header.payload using the expected key and algorithm, compares it to the third segment in constant time, and only then reads the claims. A token that fails this is not a slightly suspect token; it is arbitrary text someone sent you.
Every mainstream library has both, and they are easy to confuse because the decode-only function is often the more convenient one. In jsonwebtoken the pair is jwt.decode() and jwt.verify(); in PyJWT it is jwt.decode(token, options={"verify_signature": False}) versus the real call with a key. If server code calls the decode-only variant on a request path, the authentication is decorative.
Two classic attacks on the alg header
alg: none. The JWS spec includes an "unsecured" mode where alg is none and the signature segment is empty. A verifier that reads alg out of the header and does what it says will happily accept a token signed by nobody. The fix is to decide the acceptable algorithm on the server and pass it in explicitly, rather than trusting the token to name it.
RS256 → HS256 confusion. If a service verifies RS256 tokens with a public key, and an attacker re-signs a forged token as HS256 using that public key as the HMAC secret, a naive verifier that picks the algorithm from the header will accept it — the public key is, after all, public. Same fix: pin the algorithm.
// pin it, always
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com'
});
Modern versions of the well-known libraries default to rejecting none and require an explicit algorithm list, but the setting is still yours to get wrong, and older pinned dependencies are still out there.
The revocation problem
The appeal of a JWT is that verification is local: given the key, a service can validate a token without a round trip to an auth server or a session store. That property is also the drawback, because it means nothing you do centrally can un-issue a token that is already out there.
If a user logs out, changes their password, or has their account disabled, their existing token stays valid until exp. Practical answers, roughly in order of how much they give back:
- Short expiry — minutes, not days — paired with a refresh token that is checked against a store. The window of a stale token stays small.
- A denylist of
jtivalues for revoked tokens, checked on each request. This restores a lookup, but only for the rare revoked case rather than every request. - A per-user token version claim, compared to a counter in the database. Bumping the counter invalidates everything issued before it.
If your access tokens live for a week and you have no denylist, "log out everywhere" is a button that does nothing.
Where to keep one in a browser
There is no option here without a trade-off, so the honest framing is which attack you would rather be exposed to.
localStorage— readable by any JavaScript on the page. One XSS, in your code or in a dependency, and the token is exfiltrated. Not vulnerable to CSRF, because nothing attaches it automatically.httpOnlycookie — unreadable from JavaScript, so XSS cannot lift it directly. The browser attaches it automatically, which is what CSRF exploits, so this needsSameSite=LaxorStrictplusSecure.
The common recommendation is the cookie, because XSS is more prevalent than CSRF and SameSite defaults have improved. But an httpOnly cookie does not make XSS harmless — script running on your origin can still make authenticated requests, it just cannot take the token elsewhere.
And whichever you pick: nothing sensitive goes in the payload. Not a password hash, not an API key, not a national ID. The payload is Base64, and Base64 is not a lock.
When a JWT is the wrong tool
A signed, self-contained token earns its complexity when verification needs to happen somewhere that cannot cheaply reach the session store: many services, other teams, other companies, edge workers. That is what it was designed for.
For a single web app with one backend and a database it already talks to on every request, a plain opaque session ID in an httpOnly cookie is simpler and strictly better on revocation: the server looks it up, and deleting the row logs the user out immediately. Reaching for JWTs there buys the revocation problem and the algorithm-confusion footguns in exchange for a lookup you were doing anyway.
Last updated 10 August 2026