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=Strictcookie 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.
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
HttpOnlycookies (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.