A JSON Web Token (JWT) packs a user's identity and claims into a signed string that any service can verify without a shared session store. That convenience is why JWTs are everywhere, and why they appear in so many incidents: a token is a bearer credential, the payload is readable by anyone, and a verifier done slightly wrong accepts tokens the attacker wrote.
In this lesson you will learn what a JWT actually contains, the classic verification mistakes and how to pin them shut, where to store tokens in a browser, and how to handle expiry and revocation with refresh tokens.
A JWT is three base64url-encoded parts joined by dots: header, payload and signature.
{ "alg": "RS256", "typ": "JWT", "kid": "2026-03" }{ "sub": "u_42", "role": "member", "iss": "https://auth.example.com", "aud": "shop-api", "exp": 1760000900 }Base64url is an encoding, not encryption: anyone holding the token can read every claim. Never place passwords, personal data or secrets in the payload. The signature proves that the issuer produced these exact bytes; it does not hide them.
Most JWT vulnerabilities are in the verifier, not the token:
| Mistake | What the attacker does | Fix |
|---|---|---|
| Accepting alg: none | Sends an unsigned token with any claims | Pin algorithms: ["RS256"] in verify |
| Algorithm confusion | Signs with HS256 using your public RSA key as the HMAC secret | Same fix: never let the token choose the algorithm |
| Decoding instead of verifying | jwt.decode() returns claims without checking the signature | Use jwt.verify() on every request |
| Ignoring exp, iss, aud | Replays an old token or one issued for another service | Pass issuer and audience; libraries check exp by default |
The sample code pins the algorithm, issuer and audience. Prefer RS256 or ES256 when several services verify tokens: they receive only the public key and cannot forge tokens even if compromised. A kid header lets verifiers pick the right key from a JWKS endpoint, so keys can rotate without logging anyone out.
There is no perfect answer, only trade-offs:
localStorage: readable by any script on the page, so a single XSS bug steals the token outright. Avoid for anything sensitive.HttpOnly cookie: invisible to scripts, sent automatically. Needs SameSite=Lax or Strict plus a CSRF token for state-changing requests.HttpOnly cookie.For a single-page app the usual pattern is a short-lived access token in memory and a refresh token in an HttpOnly, Secure, SameSite=Strict cookie scoped to the refresh endpoint's path.
A signed token is valid until it expires, and the server cannot take it back. Design around that:
// Refresh endpoint: rotate on every use and detect reuse
app.post("/auth/refresh", async (req, res) => {
const stored = await RefreshToken.findOne({ hash: sha256(req.cookies.rt) });
if (!stored) return res.status(401).end();
if (stored.usedAt) { // an old token was replayed: the family is compromised
await RefreshToken.deleteMany({ family: stored.family });
return res.status(401).end();
}
stored.usedAt = new Date(); await stored.save();
const next = await RefreshToken.issue(stored.userId, stored.family); // new token, same family
res.cookie("rt", next.raw, { httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh" });
res.json({ accessToken: issueAccessToken(await User.findById(stored.userId)) });
});tokenVersion claim and reject tokens with an older version.Why must the verifier pin the allowed algorithms instead of reading `alg` from the token?
verify, never just decode, and pin algorithms, issuer and audience.kid and JWKS across services.HttpOnly cookies, never in localStorage, and keep them short-lived.Next lesson: OAuth 2.0 and OpenID Connect — delegating login to an identity provider without inheriting its pitfalls.