JWT authentication gets misused constantly because the format looks simple — three base64 segments — while the actual security guarantees it provides are easy to get subtly wrong.
A JWT (JSON Web Token) is a compact, self-contained token consisting of a header, payload, and signature, used to represent claims (like a user ID) that a server can verify without a database lookup, because the signature proves the payload hasn't been tampered with since it was issued. That "no database lookup" property is JWT's core value proposition — and also the source of its most common misuse: forgetting that a valid signature doesn't mean a session is still supposed to be active.
Why JWT Matters (and When to Skip It)
JWTs are the right tool when you need stateless verification — microservices that need to validate a token without calling back to an auth service, or APIs where a database round-trip per request is a real cost you're trying to avoid. The signature alone proves authenticity without touching a session store.
Skip JWTs (in favor of opaque session tokens + a database lookup) when instant revocation matters more than statelessness — a banned user, a compromised session, a logged-out device all need to actually lose access immediately, which a stateless JWT can't do until it naturally expires.
Getting Started with JWT Authentication
Signing and verifying with a library like jsonwebtoken:
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: "15m" }
);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; role: string };
} catch (err) {
// signature invalid or token expired
}
A JWT's three segments, decoded, look like this:
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "user_123", "role": "admin", "iat": 1735689600, "exp": 1735690500 }
// signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
Core JWT Concepts Every Developer Should Know
The payload is readable by anyone, signed but not encrypted. Base64 is not encryption — anyone with the token can decode and read the payload, they just can't forge a valid signature for a modified one. Never put secrets (passwords, raw credit card numbers) in a JWT payload.
Short-lived access tokens plus refresh tokens solve the revocation problem partially. A 15-minute access token limits the damage window if compromised; a longer-lived refresh token (stored server-side or as an httpOnly cookie, checkable against a revocation list) handles renewal:
// access token: short-lived, stateless, used on every request
// refresh token: longer-lived, stored server-side, checked against a revocation list on renewal
async function refreshAccessToken(refreshToken: string) {
const stored = await db.refreshTokens.findValid(refreshToken);
if (!stored) throw new Error("Invalid or revoked refresh token");
return jwt.sign({ sub: stored.userId }, process.env.JWT_SECRET!, { expiresIn: "15m" });
}
Algorithm confusion is a real, documented attack class. Some JWT libraries historically accepted alg: none or let an attacker switch from asymmetric (RS256) to symmetric (HS256) verification, tricking the server into verifying a forged token against a public key treated as a shared secret. Fix: always explicitly specify allowed algorithms when verifying, never trust the alg header from the token itself.
jwt.verify(token, secret, { algorithms: ["HS256"] }); // explicit allowlist
exp (expiry) should always be set and always be checked. A JWT without expiry is a permanent, unrevokable credential — treat a missing exp claim as a configuration bug, not a convenience.
Common JWT Mistakes and How to Fix Them
Mistake 1: storing JWTs in localStorage. This exposes tokens to any XSS vulnerability on the page, since JavaScript can read localStorage freely. Fix: store tokens in httpOnly cookies, which JavaScript can't access, meaningfully reducing XSS token theft risk.
Mistake 2: treating JWTs as inherently revocable. A signed, unexpired JWT remains valid until it expires, full stop — there's no way to "delete" it server-side unless you maintain a blocklist, which reintroduces the statefulness JWTs were meant to avoid. Fix: keep access token lifetimes short and put real revocation logic on the refresh token layer instead.
Mistake 3: putting too much or sensitive data in the payload. A bloated payload adds size to every request; a payload including sensitive fields the client shouldn't see (since it's just base64, not encrypted) is a real data exposure. Fix: keep the payload minimal — user ID and role are usually enough; fetch anything else from your database when needed.
When Should You Use JWTs Instead of Opaque Session Tokens?
Use JWTs for stateless service-to-service auth, or when your access token lifetime is short enough that the revocation gap is acceptable. Use opaque session tokens with a database/cache lookup when instant revocation is a hard requirement — banking apps, admin panels, anything security-sensitive enough that "wait up to 15 minutes for a ban to take effect" isn't acceptable.
JWT Authentication in Production
Rotate your signing secret periodically and support verifying against both the old and new secret during a transition window, rather than invalidating every active session on rotation. Also log and monitor for repeated invalid-signature verification failures — that pattern is a signal of active token forgery attempts, not just expired tokens from normal use.
Before reaching for JWTs by default, ask whether you actually need statelessness — if you don't, an opaque session token with a fast cache lookup (Redis) sidesteps the entire revocation problem for a negligible performance cost.