intermediate·7 min read·Updated May 1, 2026

Token Refresh Patterns: Rotating, Sliding, and Reactive Refresh

Short-lived access tokens need graceful refresh. Three common patterns, one big pitfall.

Why refresh tokens exist

Access tokens should be short-lived (15 min – 1 hour) so that a leaked token is usable for a short window. But users shouldn't have to log in every 15 minutes. Refresh tokens bridge the gap:

  • Access token — short-lived, sent on every API call.
  • Refresh token — long-lived, stored securely, only ever sent to /auth/refresh.

When the access token expires, the client sends the refresh token and gets a new access token (and often a new refresh token).

The basic refresh call

POST/auth/refresh
Exchange a refresh token for a new access + refresh pair.
curl -X POST 'https://demo.totalshiftleft.ai/auth/refresh' \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"rt_demo_abc123"}'

Response:

{
  "access_token": "new_access_token...",
  "refresh_token": "new_refresh_token...",
  "expires_in": 900
}

Three patterns govern how refresh tokens behave. Pick one deliberately.

Pattern 1: Static refresh token

The refresh token stays the same across refreshes. Only the access token changes.

Pros: Simple. Client only needs to track one refresh token.

Cons: If the refresh token leaks, the attacker has persistent access until the refresh token's own expiry (often weeks or months). No way to detect theft.

Verdict: Only acceptable for low-security systems or server-to-server flows where the refresh token is stored in a vault.

Every refresh issues a new refresh token and invalidates the old one. Client stores the new one; old one is dead.

Pros:

  • Theft detection. If an attacker uses a stolen refresh token, the legitimate client's next refresh will fail (the old token is gone). You know the moment theft happens.
  • Shorter effective window for leaked tokens.

Cons: Clients must handle token updates correctly — no race conditions between two tabs trying to refresh simultaneously.

Verdict: Default for user-facing apps. OAuth 2.1 recommends this.

Pattern 3: Sliding expiration

Each refresh extends the overall session expiry. Active users stay logged in indefinitely; inactive users get kicked out after the sliding window.

Pros: Good UX. Users in the middle of a workflow don't get logged out.

Cons: Never-expiring session for actively-used stolen tokens. Combine with absolute max-session-age (e.g., "no matter what, log out after 30 days").

Verdict: Common in consumer apps. Combine with rotation and an absolute cap.

Detecting refresh token theft

When using rotation, the theft detection logic is:

  1. Client A (legitimate) refreshes, gets RT_2, discards RT_1.
  2. Attacker also has RT_1 somehow. Tries to use it.
  3. Server sees RT_1 — it's in the "used" table (or just not in the valid table).
  4. Server flags this as suspicious: revoke all of the user's refresh tokens and force re-login.

Without that "revoke all" step, the attacker just races the legitimate user.

The race condition

Two tabs opened, access token expires, both tabs try to refresh:

  • Tab A: POST /auth/refresh { refresh_token: RT_1 } → gets RT_2.
  • Tab B: POST /auth/refresh { refresh_token: RT_1 } → fails (already used).
  • Tab B's user sees a logout they didn't expect.

Solutions:

  • Grace window — the old refresh token works for 30 seconds after rotation. Enough for a race, not enough for theft.
  • Broadcast channel — Tab A's new token is broadcast to Tab B via BroadcastChannel or localStorage event.
  • Mutex — a browser mutex ensures only one refresh is in flight per origin.

Typical answer: grace window + broadcast channel. Mutex alone doesn't help across page reloads.

What to test

Happy paths

  • Valid refresh token → new access + refresh pair.
  • New access token works on API endpoints.
  • New refresh token works for the next refresh.

Security — rotation

  • Old refresh token used after rotation → 401 (or 200 if within grace window, then 401 after grace window).
  • Refresh token reuse triggers revoke-all (simulate by refreshing, then trying the old token, then trying the new one — new one should also fail).
  • Refresh token issued for user A used by user B — 401 (user context binding).

Security — general

  • Refresh token after user password change → 401.
  • Refresh token after logout → 401.
  • Refresh token with different client_id than the one it was issued to → 401.
  • Refresh token at exp → 401.
  • Refresh token introspected — active: false after revoke.

Edge cases

  • Simultaneous refresh (two tabs) — assert grace window behavior.
  • Refresh when access token is not expired — should still work (client might refresh proactively).
  • Missing body {} → 400 invalid_request.
  • Refresh token in query string (?refresh_token=...) — should be rejected or logged as a security warning.

Absolute limits

  • Refresh 100 times in quick succession — should work (no limit on frequency, only on session age).
  • Refresh past absolute max session age — 401 session_expired, user must re-login.

Storage recommendations

Browser clients:

  • Access token: in-memory only. Never localStorage.
  • Refresh token: httpOnly, Secure, SameSite=Strict cookie. Never JavaScript-accessible.
  • The /auth/refresh endpoint reads the cookie and returns a new access token in the response body.

Mobile clients:

  • Access token: in-memory.
  • Refresh token: OS keychain (Keychain on iOS, Keystore on Android). Never in app preferences.

Backend clients (CLI tools, scripts):

  • Access token: in-memory.
  • Refresh token: OS-specific secret store (macOS Keychain, Windows Credential Manager, Linux Secret Service).

Common bugs refresh tests catch

1. No revocation on password change. User changes password because they suspect compromise; old refresh tokens keep working.

2. No client_id binding. Refresh token issued for Mobile App works on Web App — any secret leak cascades.

3. Grace window too long. "30 minutes of leeway" is a 30-minute window for an attacker.

4. Refresh token rotation doesn't mark old as invalid. Rotation without invalidation is not rotation — it's just issuing extra tokens.

5. 500 on expired refresh token. Should be 401 with a clear code so clients log the user out gracefully instead of showing an error page.

What's next

Auth cluster done. Next cluster: testing patterns — validation, retries, negative testing, and contract testing.

Related lessons

Read more on the blog