Guide · sessions

Refresh token rotation explained

Short-lived JWTs keep a stolen token useless within minutes — but nobody wants to log in every fifteen minutes. Refresh tokens square that circle, and rotation makes them safe.

First principles

The problem refresh tokens solve

JWT access tokens are stateless and self-contained— that's what makes them fast to verify, because a server can check one without a database lookup. But it's also their biggest weakness: a JWT can't easily be revoked before it expires. If a token is stolen, it stays valid until its exp time, no matter what you do.

The standard mitigation is to keep access tokens short-lived — typically 5 to 15 minutes. That shrinks the window an attacker has if a token leaks. But a 15-minute login would be miserable for users. Refresh tokens resolve the tension: a short access token for actually calling APIs, plus a longer-lived refresh token whose only job is to mint new access tokens silently in the background.

Access tokens vs refresh tokens

Access token

  • Usually a JWT; sent on every API request.
  • Short-lived (5–15 minutes).
  • Verified statelessly via signature.
  • Carries identity, scopes, permissions.

Refresh token

  • Opaque random string or a JWT; sent only to the token endpoint.
  • Long-lived (days to weeks).
  • Tracked server-side so it can be revoked.
  • Exchanged for new access tokens.

The key difference: the access token is verified by signature alone (stateless), while the refresh token is checked against server-side state, which is what makes revocation possible. The refresh token is the more sensitive of the two — it's the long-lived credential — so it's sent across the wire as rarely as possible.

What "rotation" means

Without rotation, a refresh token is a single long-lived secret used over and over for weeks. If it's stolen, the attacker can quietly mint access tokens for as long as the refresh token lives, and you may never notice.

Refresh token rotation changes that: every time a refresh token is used to get a new access token, the server also issues a brand-new refresh token and invalidates the old one. A given refresh token is therefore valid for exactly one use. The session continues seamlessly — the client just swaps in the new refresh token — but each token only exists for one cycle, dramatically narrowing the window of exposure.

Reuse detection: the part that catches theft

Rotation unlocks a powerful security property. Because each refresh token may be used only once, if the server ever sees an already-used (rotated-out) refresh token presented again, something is wrong — almost certainly the token was stolen and is being replayed.

When that happens, the server treats it as a breach signal: it revokes the entire token family — every refresh token descended from that session — forcing both the legitimate user and the attacker to re-authenticate. This is how a stateless-ish JWT system regains the ability to detect and shut down a compromised session, something a plain long-lived JWT can never do.

The full flow

// 1. Login — issue both tokens
POST /login
  → access_token  (JWT, exp in 15 min)
  → refresh_token (random id, stored server-side, exp in 14 days)

// 2. Call APIs with the access token until it expires
GET /api/data
  Authorization: Bearer <access_token>

// 3. Access token expired → silently refresh
POST /token/refresh
  body: refresh_token
  → verify it exists and is unused
  → issue NEW access_token + NEW refresh_token
  → mark the old refresh_token as used (rotated)

// 4. Reuse detected → revoke the whole family
POST /token/refresh
  body: an already-used refresh_token
  → revoke every token in this session
  → force re-login

Steps 3 repeats invisibly for the life of the session. The user only sees step 1 (login) and, if a theft is detected at step 4, a single forced re-login.

Where to store the tokens

Storage is where many implementations go wrong. The guidance from the best practices guide applies directly:

  • Access token: keep it in memory (a JavaScript variable or app state) for browser SPAs — not localStorage, which is readable by any injected script.
  • Refresh token: store it in an HttpOnly; Secure; SameSite=Strict cookie so JavaScript cannot read it and it can't be exfiltrated by XSS. On native mobile, use the OS keychain or keystore.
  • Scope the refresh cookie to the token endpoint path so it isn't sent on every request.
Reference

Implementation checklist

  • Access tokens are short-lived (≤ 15 min) and verified by signature only.
  • Refresh tokens are tracked server-side so they can be revoked.
  • Every refresh rotates: issue a new refresh token, invalidate the old one.
  • Reusing a rotated token revokes the entire token family.
  • Refresh tokens live in HttpOnly cookies (web) or secure storage (mobile).
  • Refresh tokens have an absolute maximum lifetime, after which re-login is required.
  • Logout revokes the active refresh token immediately.

Want to see what's inside your own access token? Decode it with the JWT decoder to inspect its exp and claims, and read JWT vs OAuth for how refresh tokens fit into the OAuth 2.0 framework.