How JWTs work, access vs refresh tokens, storage best practices, token rotation, and revocation strategies.
How JWTs work, access vs refresh tokens, storage best practices, token rotation, and revocation strategies.
BeforeMerge offers hundreds of code review rules, guides, and detection patterns to help your team ship better code.
JSON Web Tokens (JWTs) are the standard for stateless authentication in modern web applications.
A JWT has three parts separated by dots:
header.payload.signature{"alg": "HS256", "typ": "JWT"})HMAC-SHA256(base64(header) + "." + base64(payload), secret)The server verifies the signature to ensure the token hasn't been tampered with.
| Property | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Obtain new access tokens |
| Lifetime | Short (15 min) | Long (7-30 days) |
| Storage | Memory or httpOnly cookie | httpOnly cookie only |
| Sent with | Every API request | Only to token refresh endpoint |
1. User logs in with credentials
2. Server returns access token + refresh token
3. Client sends access token with each request
4. When access token expires (401), client uses refresh token
5. Server verifies refresh token, returns new access token
6. If refresh token is expired, user must log in again// Server: Set tokens as httpOnly cookies
res.cookie("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 15 * 60 * 1000, // 15 minutes
path: "/",
});
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 refresh endpoint
});Rotate refresh tokens on every use to limit the window of a stolen token:
async function refreshTokens(oldRefreshToken: string) {
const payload = verifyRefreshToken(oldRefreshToken);
// Invalidate the old refresh token
await db.refreshToken.delete({ where: { token: oldRefreshToken } });
// Issue new pair
const newAccessToken = signAccessToken({ userId: payload.userId });
const newRefreshToken = signRefreshToken({ userId: payload.userId });
await db.refreshToken.create({ data: { token: newRefreshToken, userId: payload.userId } });
return { newAccessToken, newRefreshToken };
}JWTs are stateless — you can't "invalidate" them directly. Strategies:
Keep access tokens short (15 min). Revocation happens naturally.
Store revoked token IDs in Redis with TTL matching token expiry:
async function revokeToken(tokenId: string, expiresIn: number) {
await redis.setex(`revoked:${tokenId}`, expiresIn, "1");
}
async function isRevoked(tokenId: string): Promise<boolean> {
return (await redis.get(`revoked:${tokenId}`)) !== null;
}Store a tokenVersion on the user. Increment on logout. Reject tokens with old version.
const payload = {
sub: userId, // Subject (who)
iat: Date.now(), // Issued at
exp: Date.now() + 15 * 60 * 1000, // Expires
jti: crypto.randomUUID(), // Unique ID for revocation
role: "admin", // Keep claims minimal
};Do NOT store sensitive data (passwords, PII) in the payload — it's base64-encoded, not encrypted.