I've reviewed a lot of codebases where JWT was implemented in a way that looked correct but had serious security holes. The three most common mistakes: storing tokens in localStorage (XSS-vulnerable), using long-lived access tokens without refresh rotation, and not validating the alg header (allows algorithm confusion attacks). Let's fix all of these.
Store your access token in an HttpOnly cookie. It's inaccessible to JavaScript, which means XSS attacks can't steal it. Pair with SameSite=Strict and Secure flags:
res.cookie('access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
});Keep access tokens short-lived (15 minutes). Use a longer-lived refresh token (7 days) to issue new access tokens silently.
Each time a refresh token is used, issue a new one and invalidate the old one. Store a hash of each refresh token in your database. If an attacker steals a refresh token and uses it after you've already rotated it, the mismatch is detected and all sessions for that user are revoked:
async function rotateRefreshToken(oldToken, userId) {
const stored = await db.refreshTokens.findOne({ userId });
if (!stored || !bcrypt.compare(oldToken, stored.hash)) {
// Token reuse detected — revoke all sessions
await db.refreshTokens.deleteAll({ userId });
throw new Error('Token reuse detected');
}
const newToken = crypto.randomBytes(64).toString('hex');
await db.refreshTokens.update({ userId }, { hash: bcrypt.hash(newToken) });
return newToken;
}Define permissions as data, not logic. A clean RBAC middleware checks the user's role against a permission map:
const permissions = {
admin: ['read', 'create', 'update', 'delete'],
manager: ['read', 'create', 'update'],
staff: ['read', 'create'],
viewer: ['read'],
};
export const authorize = (...requiredPerms) => (req, res, next) => {
const userPerms = permissions[req.user.role] ?? [];
const hasAll = requiredPerms.every(p => userPerms.includes(p));
if (!hasAll) return res.status(403).json({ error: 'Forbidden' });
next();
};
// Usage
router.delete('/users/:id', authenticate, authorize('delete'), deleteUser);Compose with the authenticate middleware that verifies the JWT signature and attaches req.user.
For APIs that don't need database lookups on every request, embed the user's role (and optionally their permissions) directly in the JWT payload. The token is signed, so it can't be tampered with. The tradeoff: permission changes don't take effect until the token expires. For most systems, 15-minute access tokens make this acceptable.
const token = jwt.sign(
{ sub: user.id, role: user.role, tenantId: user.tenantId },
process.env.JWT_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' } // Always specify algorithm
);Always specify the algorithm option. Libraries that accept alg: "none" can be tricked into skipping signature verification.