← Back to postsCoding Notes
EnglishPublished Jun 18, 2026Updated Jun 18, 20266 min read

Production-Grade Secure JWT Authentication

Tips

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.

TokenTTLPurposeSent on
Access token5–15 minAuthorizes API requestsEvery API call
Refresh token7–30 daysMints new access tokensOnly 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:

http
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
StorageXSS exposureCSRF 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.

code
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.

code
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.

json
{
  "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.

text
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.

text
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)

http
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

text
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 tokens
  • nbf — reject not-yet-valid tokens
  • iss / aud — confirm the token was issued by you, for you
  • alg — pin the expected algorithm; reject alg: none and 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 Path so 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=Strict cookie (not localStorage)
  • Access token in memory only
  • Refresh token rotation on every use
  • Reuse detection → revoke entire token family
  • jti on 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 pin alg on every request
  • HTTPS + HSTS everywhere
  • Rate limiting on login and refresh
  • Logging/alerting on reuse-detection and revocation events