Production-Grade Secure JWT Authentication
A practical reference for implementing access/refresh token auth that handles theft, revocation, and logout correctly. The core principle throughout: a stateless token cannot be un-issued, so possession equals access until expiry. Real security requires some server-side state.
1. Token Roles and Lifetimes
Use two tokens with different jobs and very different lifetimes.
| Token | TTL | Purpose | Sent on |
|---|---|---|---|
| Access token | 5–15 min | Authorizes API requests | Every API call |
| Refresh token | 7–30 days | Mints new access tokens | Only the refresh endpoint |
Keep access tokens short so a stolen one is useless within minutes. The refresh token is the long-lived, high-value credential — protect it accordingly.
2. Where to Store the Refresh Token
Do not store refresh tokens in localStorage. Any JavaScript on the origin can read it, so a single XSS flaw exfiltrates it.
Store it in a cookie with all three protective attributes:
Set-Cookie: refresh_token=<token>;
HttpOnly; # JavaScript cannot read it → closes the XSS theft path
Secure; # HTTPS only → never sent in cleartext
SameSite=Strict; # not sent on cross-site requests → blocks CSRF
Path=/auth/refresh; # only sent to the refresh endpoint, not every request
Max-Age=604800
| Storage | XSS exposure | CSRF exposure |
|---|---|---|
localStorage | ❌ Vulnerable (JS-readable) | ✅ Immune |
HttpOnly cookie | ✅ Protected | ❌ Vulnerable → needs mitigation |
Moving to a cookie trades the XSS theft path for CSRF. That's a good trade because CSRF has clean, well-understood defenses (Section 6).
Access token: keep in memory (a JS variable / app state), not persisted. It's short-lived and re-fetched via the refresh token, so it never needs to survive a page reload on disk.
3. Refresh Token Rotation with Reuse Detection
This is the single most important defense. On every refresh, issue a new refresh token and invalidate the old one.
Client → POST /auth/refresh (old refresh token RT1)
Server → validates RT1, marks RT1 used
→ issues RT2 (new) + new access token
→ returns RT2, client replaces RT1
Reuse detection: if a refresh token that was already used (RT1) is presented again, it means either the attacker or the legit client is replaying a dead token. You can't tell which — so revoke the entire token family, forcing re-login.
If a USED refresh token is presented again:
→ revoke the whole token family (all descendants)
→ force re-authentication
This converts silent, indefinite compromise into a detectable, containable event.
4. The jti Claim — Tracking Individual Tokens
jti (JWT ID, per RFC 7519) is a unique identifier per token. It's the hook that lets you track or revoke a specific token without storing the whole string.
{
"sub": "user_123",
"jti": "a1b2c3d4-9f8e-47b6-bc12-0a1b2c3d4e5f",
"iat": 1718600000,
"exp": 1718600900
}
Generate a fresh random jti (e.g. a UUIDv4) per issued token. It powers both revocation (Section 5) and replay prevention.
5. Server-Side Revocation — Making Logout Actually Work
A stateless JWT is validated by signature + expiry, not by a lookup — so the server has no built-in way to know a token was logged out. Deleting a token client-side does nothing to the token itself; anyone holding a copy (Postman, an attacker) keeps using it.
To make logout mean something, maintain server-side state. Two equivalent approaches:
Denylist (blocklist) — store revoked jtis; reject any match.
On logout: store refresh token jti in Redis with TTL = token's remaining life
On refresh: reject if jti is in the denylist
Auto-cleanup: entries expire when the token would have expired anyway
Allowlist / session store — track valid sessions; reject anything not present.
On login: create a session record keyed by refresh token family / jti
On refresh: reject if the session record is absent or revoked
On logout: delete the session record (or the whole token family)
Redis is the typical store: fast lookups, native TTL for self-cleanup. With rotation you're already tracking families, so logout becomes "invalidate this family."
What logout revokes — and what it doesn't
- Refresh token: revoked immediately. No new access tokens can be minted.
- Already-issued access tokens: stay valid until they expire (minutes). This is the accepted cost of stateless access tokens — keep TTL short so the post-logout window is tiny.
- Need instant access-token kill? Check the denylist on every request — but that sacrifices the statelessness you adopted JWTs for. Only do this for high-security systems where the tradeoff is justified.
6. CSRF Protection (Required When Using Cookies)
CSRF tricks the victim's browser into firing an authenticated request at your site, because browsers auto-attach cookies to any request to a domain regardless of which site initiated it. The attacker causes an action; they never read the response.
Use defense in depth:
1. SameSite cookie attribute (first line of defense)
SameSite=Strict # never sent cross-site — strongest
SameSite=Lax # sent only on top-level GET navigations — common default
Strict is ideal for auth/refresh cookies since you control when they're needed.
2. CSRF token (for state-changing endpoints)
The server issues a random value the legit frontend must echo back in a header. A cross-origin attacker can't read it (blocked by the same-origin policy), so it can't forge a valid request. The double-submit cookie pattern is a common stateless implementation.
Use SameSite and a CSRF token for important POST/PUT/DELETE endpoints — not either alone.
7. JWT Signing and Validation
Algorithm: RS256 / ES256 (asymmetric) preferred over HS256 for multi-service setups
— services verify with the public key, only the auth server holds the private key
Secret/keys: long, random, stored in a secrets manager (Vault, AWS Secrets Manager) — never in code
Rotation: rotate signing keys periodically; use a `kid` header to support overlap
Always validate on every request:
exp— reject expired tokensnbf— reject not-yet-valid tokensiss/aud— confirm the token was issued by you, for youalg— pin the expected algorithm; rejectalg: noneand unexpected algorithms (classic JWT bypass)- Signature against the correct key
8. Transport and Hardening
- HTTPS everywhere. TLS only; HSTS enabled. Tokens in cleartext are game over.
- Rate-limit auth endpoints (login, refresh) to blunt brute-force and token-guessing.
- Bind tokens to a device fingerprint / IP where feasible — adds friction to stolen-token reuse.
- Log and alert on reuse-detection events and revocation-family triggers — these signal active compromise.
- Minimize claims. Don't put secrets or PII in a JWT; the payload is only base64-encoded, not encrypted.
- Scope the refresh cookie with
Pathso it isn't sent on every request, only to the refresh endpoint.
Production Checklist
- Access token TTL 5–15 min; refresh token TTL days, not longer than needed
- Refresh token in
HttpOnly+Secure+SameSite=Strictcookie (notlocalStorage) - Access token in memory only
- Refresh token rotation on every use
- Reuse detection → revoke entire token family
-
jtion every token - Server-side denylist or session store (Redis) for revocation
- Logout revokes the refresh token / family server-side
- Access token TTL short enough to bound the post-logout window
- CSRF:
SameSite+ CSRF token on state-changing endpoints - Asymmetric signing (RS256/ES256); keys in a secrets manager; key rotation via
kid - Validate
exp,nbf,iss,aud, and pinalgon every request - HTTPS + HSTS everywhere
- Rate limiting on login and refresh
- Logging/alerting on reuse-detection and revocation events