Every JWT tutorial on the internet follows the same pattern. User sends credentials. Server signs a JWT. Client stores it in localStorage. Client sends it in the Authorization header. Tutorial ends.
That implementation has at least three serious problems.
First, localStorage is accessible to any JavaScript on the page. One XSS vulnerability — one unsanitized input, one compromised third-party script — and the attacker reads and exfiltrates the token.
Second, no refresh mechanism. The token either expires quickly (user logs in constantly) or slowly (stolen token has a long window).
Third, no revocation. If the user changes their password or you detect suspicious activity, you can’t invalidate the token. It’s valid until it expires.
The Access / Refresh Token Pattern
OAuth2 standardized this approach with two tokens:
Access Token — Short-lived (15 minutes). Sent with every API request. If stolen, 15-minute window.
Refresh Token — Long-lived (7-30 days). Stored securely. Only sent to a refresh endpoint. Used to get new access tokens.
The flow:
- User logs in with credentials
- Server validates, generates access token (15 min) and refresh token (7 days)
- Client stores both securely
- Client sends access token with API requests
- When access token expires, client sends refresh token to refresh endpoint
- Server validates refresh token, issues new access token (and rotates refresh token)
- When refresh token expires, user must log in again
Step 1: Token Claims
{
"iss": "api.yourdomain.com",
"sub": "user_12345",
"aud": "yourdomain.com",
"exp": 1712345678,
"iat": 1712344778,
"jti": "unique-id-abc123",
"role": "admin"
}
iss— who created the tokensub— user IDaud— intended audienceexp— expiration (Unix timestamp)iat— issued atjti— unique token ID (needed for revocation)
Never put sensitive data in the payload. JWTs are base64 encoded, not encrypted. Anyone can decode the payload. No passwords, no SSNs, no credit card numbers.
Keep it minimal. User ID and role. That’s it. Look up everything else server-side.
Step 2: Signing Algorithm
Use asymmetric signing for production.
RS256 (RSA + SHA-256) or ES256 (ECDSA + SHA-256). The auth server holds the private key and signs tokens. API servers have only the public key and can verify tokens but cannot create them.
This matters in a microservices architecture. If you use HS256 (symmetric), every service that verifies tokens has the shared secret. Compromise one service, compromise the signing key, and the attacker can mint arbitrary tokens.
With RS256/ES256, compromising an API server doesn’t help — the attacker can verify tokens but can’t sign new ones. Only the auth server can do that.
Generate an RS256 key pair:
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
The auth server uses private.pem to sign. API servers use public.pem to verify.
Step 3: Secure Token Storage
Where NOT to store tokens:
localStorage— accessible to any JavaScript. XSS = game over.sessionStorage— same problem, just doesn’t persist across tabs.- Regular cookies without
httpOnly— JavaScript can still read them. - URL parameters — visible in server logs, browser history, referrer headers.
Where to store them:
For web apps: httpOnly cookies with Secure and SameSite attributes.
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900000 // 15 minutes
});
httpOnly means JavaScript cannot access the cookie. XSS can’t steal it. Secure means HTTPS only. SameSite: strict prevents the cookie from being sent in cross-origin requests, mitigating CSRF.
The browser sends the cookie automatically with every request to your domain. Your API reads it from the cookie header instead of the Authorization header. No JavaScript involved in token handling at all.
For mobile apps: Use platform secure storage — iOS Keychain, Android Keystore. These are hardware-backed encrypted storage that the OS protects.
For SPAs calling APIs on different domains: This is the hard case. SameSite: strict doesn’t work cross-origin. You need SameSite: none; Secure with proper CORS configuration. Or use the Backend For Frontend (BFF) pattern where your SPA talks to its own backend, and that backend handles tokens and proxies API calls.
Step 4: The Refresh Flow
When the access token expires, the client receives a 401 from the API. The client then sends the refresh token to a dedicated endpoint:
POST /auth/refresh
Cookie: refresh_token=eyJ...
The server:
- Validates the refresh token (signature, expiration, not revoked)
- Issues a new access token
- Rotates the refresh token — issues a new refresh token and invalidates the old one
- Returns both to the client
Refresh token rotation is critical for detecting theft. Here’s why:
If an attacker steals the refresh token and uses it before the legitimate user, the attacker gets a new token pair and the old refresh token is invalidated. When the legitimate user tries to refresh with the old token, it fails — and you know the refresh token was compromised. At this point, invalidate all tokens for that user and force re-authentication.
Store refresh tokens in a database (not just in-memory). Each refresh token maps to a user ID and a token family. When a refresh is requested:
- Look up the token in the database
- If it’s valid: issue new pair, mark old token as used, store new token
- If it’s already been used: someone is replaying a stolen token. Invalidate the entire family. Force the user to log in again.
Step 5: Token Revocation
JWTs are stateless — you can’t invalidate them server-side. The whole point is that the server doesn’t store session state. But sometimes you need to revoke a token immediately: logout, password change, compromised account.
Option 1: Token blocklist.
Store revoked token JTIs (unique IDs) in Redis with a TTL matching the token’s remaining lifetime. On every API request, check the blocklist:
const isRevoked = await redis.get(`blocklist:${tokenJti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
This trades some statefulness for the ability to revoke. The blocklist is small (only active tokens that have been explicitly revoked) and the TTL ensures automatic cleanup.
Option 2: Short access tokens + refresh token revocation.
Keep access tokens at 5 minutes. For logout, only revoke the refresh token (delete it from the database). The access token expires naturally within 5 minutes. This is simpler but has a 5-minute window where the old access token still works.
Option 3: Token versioning.
Store a tokenVersion counter on the user record. Include it in the JWT payload. On every request, compare the token’s version to the database. If the user’s version has been incremented (due to password change, forced logout, etc.), reject the token. This requires a database lookup per request, which somewhat defeats the stateless advantage — but it’s a pragmatic compromise.
Common Mistakes
Mistake 1: Storing the secret in code. Use environment variables or a secrets manager. Never commit signing keys to version control.
Mistake 2: Using HS256 in a microservice architecture. Every service needs the shared secret. One compromised service = all services compromised. Use RS256/ES256.
Mistake 3: Not validating aud and iss claims. A token signed by your auth server but intended for a different service should be rejected. Always validate audience and issuer.
Mistake 4: Putting too much in the payload. Every byte is sent with every request. JWTs over 1KB are a sign you’re using them wrong.
Mistake 5: Never rotating refresh tokens. If a refresh token is valid for 30 days and gets stolen on day 1, the attacker has 29 days of access. Rotation limits this to a single use.
Mistake 6: No refresh token at all. A long-lived access token is the worst of both worlds — can’t be revoked and gives a long attack window. Use the two-token pattern.
The two-token pattern with httpOnly cookies, asymmetric signing, and refresh token rotation is the production standard. It’s not the simplest implementation, but it’s the one that doesn’t get you into the security news.
If you found this guide helpful, check out our other resources:
- (More articles coming soon in Backend Engineering)