The two-token pattern
- Access token: short-lived (15 minutes), sent with every request, stored in memory
- Refresh token: long-lived (7 days), used only to get new access tokens, stored in an httpOnly cookie
Why two tokens? If an access token leaks, the damage window is 15 minutes. The refresh token is harder to steal because it is httpOnly (no JavaScript access) and only sent to one endpoint.
Token generation
function generateTokens(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', jti: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
The jti (JWT ID) on the refresh token is critical. It lets you track and revoke individual tokens.
Refresh token rotation
Every time a refresh token is used, issue a new one and invalidate the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (because the token was already rotated). This signals a compromise.
async function refresh(token: string) {
const payload = jwt.verify(token, REFRESH_SECRET);
const isValid = await redis.get(`refresh:${payload.jti}`);
if (!isValid) {
// Token was already used — possible theft. Revoke all tokens for this user.
await revokeAllTokens(payload.sub);
throw new UnauthorizedException('Token reuse detected');
}
// Invalidate the old token
await redis.del(`refresh:${payload.jti}`);
// Issue new tokens
return generateTokens(payload.sub);
}
Revocation strategy
Access tokens are stateless — you cannot revoke them without checking a blacklist on every request, which defeats the purpose. Instead, keep them short-lived. For immediate revocation (user changes password, admin bans account), maintain a small Redis set of revoked user IDs. Check it on critical endpoints only.
Where to store tokens
- Access token: in-memory variable (not localStorage — XSS can read it)
- Refresh token: httpOnly, Secure, SameSite=Strict cookie
Common mistakes I see
- Storing JWTs in localStorage (XSS vulnerability)
- Using a single long-lived token (no damage containment)
- No refresh token rotation (stolen tokens work forever)
- Putting sensitive data in the JWT payload (it is base64, not encrypted)
