
Everything That Can Go Wrong With JWTs (And How to Not Let It)
JWTs are a solved problem in the sense that the cryptographic primitives are sound. They're not solved in the sense that every team tends to re-implement them and introduce the same class of bugs.
I've audited a few internal auth systems over the years. The issues I find aren't creative they're the same handful of mistakes that JWT documentation doesn't warn you about clearly enough. Let me walk through a complete implementation and call out each decision.
What a JWT Actually Is
A JWT is three base64url-encoded JSON objects joined by dots: header.payload.signature.
- Header: algorithm and token type
- Payload: your claims (user ID, expiry, whatever you add)
- Signature: HMAC or RSA signature over the header and payload
The signature is what you verify. It proves the token was created by someone with the secret key and hasn't been tampered with since.
What a JWT is not: encrypted. The payload is encoded, not encrypted. Anyone who gets your token can decode the payload and read its contents. Don't put sensitive data in it.
typescriptimport * as jose from "jose"; // Don't do this: const payload = { userId: user.id, password: user.password, // NEVER put secrets in JWT payload creditCard: "4242...", // Never };
Mistake 1: Weak or Mishandled Secrets
For HMAC-signed tokens (HS256), your secret key needs to be:
- Long enough (at least 256 bits / 32 bytes for HS256)
- Cryptographically random
- Rotatable
typescriptimport { randomBytes } from "crypto"; // Generate a good secret (do this once, store it, don't lose it) const secret = randomBytes(32).toString("hex");
Don't use your app's password or a memorable string. Don't hardcode it. It goes in an environment variable and it should never appear in your codebase.
Mistake 2: Using the "none" Algorithm
The JWT spec has an alg: "none" option that disables signature verification. Some libraries used to accept unsigned tokens if the header said none. This allowed token forgery.
Modern libraries don't accept this by default, but make sure you're specifying allowed algorithms explicitly:
typescript// jose library example const { payload } = await jose.jwtVerify(token, secret, { algorithms: ["HS256"], // Explicitly allow only what you use // This rejects tokens claiming alg: "none" });
A Complete Implementation
Here's what I use. I'll break down each piece.
typescript// lib/jwt.ts import * as jose from "jose"; const JWT_SECRET = new TextEncoder().encode( process.env.JWT_SECRET ?? (() => { throw new Error("JWT_SECRET not set"); })(), ); const ACCESS_TOKEN_EXPIRY = "15m"; const REFRESH_TOKEN_EXPIRY = "7d"; interface TokenPayload { sub: string; // user ID (standard claim, use it) sessionId: string; type: "access" | "refresh"; } export async function signAccessToken( userId: string, sessionId: string, ): Promise<string> { return new jose.SignJWT({ sessionId, type: "access" }) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setIssuedAt() .setExpirationTime(ACCESS_TOKEN_EXPIRY) .setIssuer("your-app-name") // validate this on verify .setAudience("your-app-name") // validate this on verify .sign(JWT_SECRET); } export async function signRefreshToken( userId: string, sessionId: string, ): Promise<string> { return new jose.SignJWT({ sessionId, type: "refresh" }) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setIssuedAt() .setExpirationTime(REFRESH_TOKEN_EXPIRY) .setIssuer("your-app-name") .setAudience("your-app-name") .sign(JWT_SECRET); } export async function verifyAccessToken(token: string): Promise<TokenPayload> { const { payload } = await jose.jwtVerify(token, JWT_SECRET, { algorithms: ["HS256"], issuer: "your-app-name", audience: "your-app-name", }); if (payload.type !== "access") { throw new Error("Invalid token type"); } return payload as unknown as TokenPayload; } export async function verifyRefreshToken(token: string): Promise<TokenPayload> { const { payload } = await jose.jwtVerify(token, JWT_SECRET, { algorithms: ["HS256"], issuer: "your-app-name", audience: "your-app-name", }); if (payload.type !== "refresh") { throw new Error("Invalid token type"); } return payload as unknown as TokenPayload; }
A few things worth noting here:
sessionId claim. This is the key to revocation. The JWT itself can't be "deleted" once issued, it's valid until it expires. Adding a sessionId and checking it against your database on each request gives you revocation: delete the session row, and the next request fails even if the token is still within its expiry window.
Separate access and refresh tokens. Access tokens should be short-lived (15 minutes). Refresh tokens live longer (7 days) but only ever touch your refresh endpoint. This limits the blast radius of a stolen access token.
iss and aud claims. These prevent token confusion attacks where a valid token for one service is replayed against another.
Mistake 3: Storing Tokens in localStorage
Tokens in localStorage are accessible to any JavaScript on your page. One XSS vulnerability anywhere in your app or in any third-party script you've loaded and your tokens walk out the door.
The better option for browser clients is httpOnly cookies. They're inaccessible to JavaScript by design:
typescript// Setting the cookie after login res.cookie("access_token", accessToken, { httpOnly: true, // JS can't read this secure: true, // Only sent over HTTPS sameSite: "strict", // CSRF protection maxAge: 15 * 60 * 1000, // 15 minutes in ms }); res.cookie("refresh_token", refreshToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days path: "/api/auth/refresh", // Only sent to the refresh endpoint });
The path: '/api/auth/refresh' on the refresh token cookie means it's only sent when the browser hits that specific URL. It never goes to your regular API endpoints, which reduces exposure.
The Refresh Flow
typescript// POST /api/auth/refresh export async function refreshHandler(req: Request, res: Response) { const refreshToken = req.cookies.refresh_token; if (!refreshToken) { return res.status(401).json({ error: "No refresh token" }); } let payload: TokenPayload; try { payload = await verifyRefreshToken(refreshToken); } catch { res.clearCookie("refresh_token"); return res.status(401).json({ error: "Invalid refresh token" }); } // Check session is still valid const session = await db.session.findUnique({ where: { id: payload.sessionId }, }); if (!session || session.revokedAt) { res.clearCookie("refresh_token"); res.clearCookie("access_token"); return res.status(401).json({ error: "Session revoked" }); } // Refresh token rotation: issue a new refresh token and invalidate the old one // This detects refresh token theft (if an old refresh token is reused, someone stole it) await db.session.update({ where: { id: payload.sessionId }, data: { lastRefreshedAt: new Date() }, }); const newAccessToken = await signAccessToken( payload.sub, payload.sessionId, ); res.cookie("access_token", newAccessToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 15 * 60 * 1000, }); return res.json({ ok: true }); }
Logout
Logout should invalidate the session server-side, not just clear cookies. Clearing cookies prevents the tokens from being sent again, but an attacker who already extracted the tokens from a compromised machine can still use them until they expire.
typescriptexport async function logoutHandler(req: Request, res: Response) { const token = req.cookies.access_token; if (token) { try { const payload = await verifyAccessToken(token); // Revoke the session await db.session.update({ where: { id: payload.sessionId }, data: { revokedAt: new Date() }, }); } catch { // Token is already invalid, that's fine } } res.clearCookie("access_token"); res.clearCookie("refresh_token"); return res.json({ ok: true }); }
Should You Use an Auth Library Instead?
Probably, for anything user-facing at scale. NextAuth, Auth.js, Clerk, Lucia they've handled these cases and more. What I've shown here is useful for internal services, API-to-API auth, or for understanding what's happening inside the libraries you use.
If you do roll your own: get it reviewed by someone who's done it before, and add tests specifically for the failure cases (expired token, tampered token, revoked session, wrong token type).
The bugs in JWT implementations aren't in the happy path.