JWT Pitfalls and Token Security

Intermediate
12 min

JWT Pitfalls and Token Security

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.

What Is Inside a Token

A JWT is three base64url-encoded parts joined by dots: header, payload and signature.

json
{ "alg": "RS256", "typ": "JWT", "kid": "2026-03" }
json
{ "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.

Verification Pitfalls

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.

Where to Store Tokens in the Browser

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.
  • In memory (a JavaScript variable): lost on reload, so it pairs with a refresh token held in an 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.

Expiry, Refresh and Revocation

A signed token is valid until it expires, and the server cannot take it back. Design around that:

javascript
// 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)) }); });
  • Keep access tokens short (5 to 15 minutes) so a stolen one has limited value.
  • Store refresh tokens server-side as hashes; rotate on every use and revoke the family on reuse.
  • For "sign out everywhere", keep a per-user tokenVersion claim and reject tokens with an older version.
Quick Quiz
Question 1 of 2

Why must the verifier pin the allowed algorithms instead of reading `alg` from the token?

Key Takeaways

  • A JWT payload is readable by anyone; the signature proves origin, not confidentiality.
  • Always verify, never just decode, and pin algorithms, issuer and audience.
  • Prefer asymmetric signing (RS256/ES256) with a kid and JWKS across services.
  • Keep access tokens in memory or HttpOnly cookies, never in localStorage, and keep them short-lived.
  • Rotate refresh tokens on every use, detect reuse, and keep a revocation mechanism for logout.

Next lesson: OAuth 2.0 and OpenID Connect — delegating login to an identity provider without inheriting its pitfalls.

JWT Pitfalls and Token Security - Cyber Security | CodeYourCraft | CodeYourCraft