Refresh Tokens and Token Revocation
TL;DR — Short access tokens (15 min), long refresh tokens (7-30 days). Refresh tokens MUST be revocable (server-side store). Rotate on each use. Detect reuse → revoke entire family. The pattern that makes JWT auth survive token theft.
After JWT for sessions is usually wrong, the related: when you do use JWT, refresh tokens are how you make it bearable.
The basic flow
- Client logs in → receives
access_token(15 min) +refresh_token(30 days) - Client uses access token until it expires
- Client uses refresh token to get new access token (and a new refresh token)
- Client uses new access token; old one is now invalid (or expired)
POST /login → {access_token, refresh_token}
GET /api (with access_token in Authorization)
[15 min later — access token expired]
POST /refresh (with refresh_token) → {new_access_token, new_refresh_token}
GET /api (with new_access_token)
The access token is short-lived = limited damage if leaked. The refresh token is long-lived but revocable.
Storage
Access token: JWT (stateless, fast verify).
Refresh token: opaque random string, stored server-side. Revocable.
CREATE TABLE refresh_tokens (
id text PRIMARY KEY, -- the token itself (or hash)
user_id bigint NOT NULL,
family_id text NOT NULL, -- for rotation/reuse detection
issued_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used boolean NOT NULL DEFAULT false,
parent_id text -- the refresh token this was generated from
);
CREATE INDEX ON refresh_tokens (user_id);
CREATE INDEX ON refresh_tokens (family_id);
For high security, store the SHA-256 hash of the token, not the token itself. Compromise of the DB doesn’t give attackers usable tokens.
Rotation on each use
Each refresh exchange returns a NEW refresh token. The old one is marked used; using it again is suspicious.
def refresh(old_refresh: str) -> tuple[str, str]:
row = db.execute("""
SELECT * FROM refresh_tokens
WHERE id = :id AND used = false AND expires_at > now()
""", {"id": hash(old_refresh)}).fetchone()
if not row:
# Either expired, used, or never existed
# If it WAS valid before but now used = reuse attack
check_for_reuse(old_refresh)
raise Unauthorized()
# Mark used
db.execute("UPDATE refresh_tokens SET used = true WHERE id = :id",
{"id": hash(old_refresh)})
# Issue new
new_refresh = secrets.token_urlsafe(48)
db.execute("""
INSERT INTO refresh_tokens (id, user_id, family_id, expires_at, parent_id)
VALUES (:id, :uid, :fid, :exp, :parent)
""", {
"id": hash(new_refresh),
"uid": row.user_id,
"fid": row.family_id, # same family
"exp": now + timedelta(days=30),
"parent": row.id,
})
new_access = issue_access_token(row.user_id)
return new_access, new_refresh
Used tokens are kept (not deleted) for reuse detection.
Reuse detection — the security win
If an attacker steals a refresh token AND uses it:
- Attacker gets new access + refresh
- Legitimate user later tries to refresh with OLD token
- Server sees: “this token was already used; alarm”
- Server invalidates ALL refresh tokens in the family (legitimate user’s session AND attacker’s)
def check_for_reuse(token: str):
row = db.execute("""
SELECT family_id FROM refresh_tokens WHERE id = :id AND used = true
""", {"id": hash(token)}).fetchone()
if row:
# Reuse detected — revoke whole family
db.execute("""
UPDATE refresh_tokens SET used = true
WHERE family_id = :fid
""", {"fid": row.family_id})
alert_security_team(row)
Either the attacker is locked out, or the legitimate user has to log in again (mild annoyance, real protection).
Revocation
For “log out all sessions” or “user changed password”:
UPDATE refresh_tokens SET used = true
WHERE user_id = $1;
All refresh tokens for that user invalidated. Access tokens still work until expiry (max 15 min), but no refresh = no extension.
For “log out everywhere right now”: couple with a short-lived denylist for active access tokens. Or accept the 15-minute window.
Storage of refresh tokens client-side
Web app:
- httpOnly cookie (preferred — XSS-safe)
- localStorage (XSS-vulnerable but works without server cookie domain)
Native app:
- iOS Keychain / Android KeyStore (preferred)
- Encrypted SharedPreferences as fallback
Never:
- localStorage on web for sensitive apps (XSS reads it)
- Plaintext file
- LocalStorage of native app without OS keychain
Sliding window vs fixed window
Fixed window: refresh token expires 30 days after issue, no matter how many refreshes.
Sliding window: refresh token’s expiry extends with each use.
Both valid. Fixed is more secure (eventually re-login forced). Sliding is friendlier UX.
I default to fixed with 30-day window. Users re-authenticate monthly. Acceptable for most consumer apps.
When NOT to use refresh tokens
For first-party web apps with single-domain cookies: sessions are simpler. Refresh tokens are for:
- Mobile apps (no cookie domain)
- SPAs talking to multiple APIs across domains
- Federated auth where the API doesn’t have a DB
- Anywhere JWT is the right choice (see JWT for sessions)
Common Pitfalls
No rotation. Token stolen = forever-valid until expiry. Rotate on each use.
Storing refresh tokens in JWT. Defeats revocation. Refresh tokens are opaque + server-side.
No reuse detection. Most of the security value comes from this.
Refresh tokens that never expire. Forever-grant. Set a max lifetime.
Access tokens as long as refresh tokens. Defeats the whole point. Access tokens must be short.
Storing tokens in localStorage on web. XSS-readable. httpOnly cookies safer.
No revocation on password change. New password = old session should die.
Wrapping Up
Short JWT access + long opaque refresh + rotation + reuse detection = JWT auth that survives token theft. Friday: OAuth 2.1 vs 2.0.