By AndyPublished

The JWT Refresh Token Pattern: Implementation and Pitfalls

A JWT cannot be revoked. That's the whole point, verification is self-contained, no server lookup needed. But "I logged out, why am I still authenticated for the next hour" is unacceptable in practice. The standard solution is to make access tokens short-lived (minutes) and use a longer-lived refresh token to mint new ones. This guide explains how the pattern works, why it's mostly an answer to JWT's revocation problem, and the implementation details that distinguish a working refresh flow from a broken one.

The Problem Refresh Tokens Solve

A stateless JWT is a bearer credential, valid until exp. Revocation in the traditional session sense requires a server-side lookup on every request, which defeats the point of self-contained tokens. So most JWT-based systems can't honour an immediate logout. They honour expiry.

The compromise: make the access token expire fast (5-15 minutes is typical). The window of unrevokable authority is short. To avoid forcing the user to log in every 15 minutes, the auth server issues a separate refresh token, a long-lived credential whose only purpose is to mint new access tokens. The refresh token IS server-tracked (in a database row), so it can be revoked. Logout deletes the refresh row; existing access tokens age out within 15 minutes.

The Flow

1. Login
   Client → /auth/login (username + password)
   Server → access_token (JWT, 15-min exp)
            refresh_token (opaque or JWT, 30-day exp, stored server-side)

2. API calls
   Client → /api/something (Authorization: Bearer <access_token>)
   Server verifies access_token signature + exp. No DB lookup.

3. Access token expires (after ~15 min)
   Client → /auth/refresh (refresh_token)
   Server: lookup refresh_token in DB, verify not revoked + not expired
   Server → new access_token + new refresh_token (rotated)
   Server: mark old refresh_token as used (rotation log)

4. Logout
   Client → /auth/logout
   Server: delete refresh_token row from DB
   Existing access_token still valid until exp (~15 min max)

Where to Store Each Token

Access token: in-memory only

Holding the access token in a JavaScript variable (NOT localStorage, NOT a non-HttpOnly cookie) minimises XSS exposure. If an attacker exfiltrates the in-memory token, they get a 15-minute window; if they exfiltrate a token from localStorage, they get whatever exp says plus the ability to read it on every page load.

Refresh token: HttpOnly cookie

The refresh token must survive page reloads (otherwise the user has to log in every time they refresh). It must NOT be accessible to JavaScript (otherwise XSS gets the long-lived credential). That points to one option: an HttpOnly cookie with Secure,SameSite=Strict, and ideally Path=/auth/refresh so the cookie is only transmitted when calling the refresh endpoint, limiting which other endpoints could leak it via response logs or misconfiguration.

See JWT Storage for the full storage tradeoffs.

Refresh Token Rotation (Mandatory, Not Optional)

On every refresh, the server should:

  • ·Issue a new refresh token (not just a new access token).
  • ·Mark the old refresh token as USED in the database.
  • ·Reject any subsequent attempt to use the old refresh token.

This catches refresh-token theft: if an attacker steals the refresh cookie and uses it once, the legitimate user's next refresh will be rejected (because the legitimate refresh token is now marked used). The legitimate user gets logged out, but you've detected the breach and can revoke the entire session family server-side.

The OAuth 2.0 Best Current Practice (RFC 9700, 2024) requires refresh-token rotation for public clients. If you're not rotating, an attacker who steals the refresh token can use it indefinitely alongside the legitimate user, neither side will notice until the absolute expiry.

Replay Detection: The Family Pattern

When rotation detects a replay (an already-USED refresh token presented again), the right response isn't just to reject the request, it's to invalidate every refresh token in the same session family. The original refresh token spawned a chain of rotations; if any earlier link in the chain is presented after being marked USED, EVERY descendant token should be revoked.

Implementation: store a family_id on each refresh-token row, set on the initial login. Every rotation copies the parent's family_id. On detection of a replay, UPDATE refresh_tokens SET revoked = true WHERE family_id = ?. The legitimate user gets logged out (one false positive cost) but the attacker can no longer use the stolen token's family.

Should the Refresh Token Be a JWT?

The refresh token can be a JWT, but it doesn't gain much from being one, refresh tokens are always validated server-side anyway (you need the DB lookup for revocation, family tracking, and rotation state), so the JWT's signature self-validation is wasted.

Most production systems use an opaque refresh token: a randomly-generated 256-bit string, stored hashed in the database (so a DB leak doesn't expose live credentials). The token the client holds is the only "live" copy; the DB stores SHA-256 hashes the same way password hashes are stored.

Lifetime Recommendations

  • ·Access token (JWT): 5-15 minutes. Shorter is safer; longer reduces refresh-endpoint load.
  • ·Refresh token (sliding window): 7-30 days, extended on every use. After 30 days of inactivity, the user has to re-authenticate fully.
  • ·Refresh token (absolute): 60-90 days hard cap, even with active use. Forces periodic re-authentication for long sessions.
  • ·"Remember me" flow: longer sliding window (30-90 days) with absolute cap of 12 months. Sensitive operations (password change, payment) still require re-authentication.

Logout: The Whole Point

A correct logout flow:

  • ·Server deletes the current refresh token's row (and ideally the entire family, since the user is leaving).
  • ·Server clears the refresh cookie via Set-Cookie: refresh=; Max-Age=0; ….
  • ·Client drops the in-memory access token.

The in-flight access token (if any) remains valid until its exp: that's the residual revocation gap. If your threat model can't tolerate even a 15-minute gap, you need either (a) shorter access tokens (1-2 minutes), or (b) a revocation list checked on every API request (which mostly defeats stateless JWT). Most apps accept the gap.

Implementation Pitfalls

  • ·Sliding window without rotation: extending the refresh token's lifetime without rotating it. An attacker who steals the token keeps it alive indefinitely as long as they keep using it.
  • ·Race conditions on rotation: two concurrent API calls both trigger refresh, both submit the old refresh token, one succeeds and one (the legitimate retry) gets a "token already used" error. Mitigate with a brief grace window (5-10 seconds) where both old and new refresh tokens are accepted.
  • ·Refresh endpoint inside main API: putting POST /api/refresh under the same path as authenticated endpoints means the refresh cookie is sent on every API call. Use a dedicated path so Path=/auth/refresh limits cookie scope.
  • ·Missing logout endpoint: clients sometimes "log out" by just dropping the access token client-side, never telling the server. The refresh token stays valid in DB; an attacker who captured the cookie can keep refreshing.
  • ·Refresh token in localStorage: defeats the entire purpose. The whole reason for the access/refresh split is so the long-lived credential isn't accessible to JavaScript.

When NOT to Use This Pattern

The refresh-token dance is overhead. If your app:

  • ·Doesn't need stateless verification (single server, no microservice fan-out), AND
  • ·Has straightforward revocation requirements

...then a traditional server-side session (random ID in a cookie, lookup table in Redis) is simpler and has fewer moving parts. See JWT vs Session Tokens. JWTs earn their complexity when verification has to happen on many servers without a shared session store; if that's not your situation, sessions are fine.

Summary

Short-lived access tokens (in-memory) plus long-lived refresh tokens (HttpOnly cookie, server-tracked, rotated on every use, with family-wide revocation on replay detection) is the canonical JWT pattern. It gives you stateless API verification without giving up the ability to log out. The key implementation details, rotation, replay detection, family revocation, scoped cookie path, are what separate a working refresh flow from a broken one. Skip it entirely if traditional sessions fit your needs.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder