Refresh tokens and rotation
Short access tokens are only safe if refresh is designed properly.
Open this lesson in the learning hubKey points
- The pattern is a short access token - minutes - plus a long refresh token used only against the token endpoint. A leaked access token then expires quickly on its own.
- That only works if the refresh token is protected better than the access token. It is the more valuable credential, because it mints new access tokens.
- Rotation: each refresh returns a new refresh token and invalidates the old one. A token used twice is evidence of theft, because the legitimate client would have discarded it.
- On detecting reuse, revoke the whole family of tokens descended from that grant. That turns a stolen refresh token into one extra request rather than indefinite access.
- Refresh tokens must be stored server-side to be revocable - which is fine, because there are far fewer of them than requests, so the lookup cost is negligible.
- A public client such as a browser or mobile app cannot keep a secret, so its refresh token must be rotated and bound to the client. For browsers, an httpOnly cookie holding the refresh token beats localStorage.
Example
# THE FLOW
#
# POST /token grant_type=authorization_code
# -> access_token (exp 15 min)
# -> refresh_token (exp 30 days, family F1, generation 1)
#
# ... 15 minutes later ...
#
# POST /token grant_type=refresh_token&refresh_token=<gen 1>
# -> new access_token
# -> new refresh_token (family F1, generation 2)
# -> generation 1 is now INVALID
---
# REUSE DETECTION - the point of rotation.
#
# attacker steals generation 2 and uses it -> gets generation 3
# legitimate client later uses generation 2 -> ALREADY USED
# -> revoke the ENTIRE family F1
# -> both parties are logged out; the user re-authenticates
#
# Without rotation the attacker refreshes silently for 30 days and
# nothing ever detects it.
---
-- What the server actually stores. Small table, cheap lookups.
CREATE TABLE refresh_token (
id UUID PRIMARY KEY,
family_id UUID NOT NULL, -- all tokens from one login
user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL, -- store a HASH, never the token
generation INT NOT NULL,
used_at TIMESTAMP NULL, -- non-null + presented = THEFT
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (token_hash),
INDEX idx_family (family_id)
);
-- On reuse:
UPDATE refresh_token SET revoked = TRUE WHERE family_id = ?;
# Browser storage, ranked:
# httpOnly + Secure + SameSite cookie best - JavaScript cannot read it
# in-memory (a JS variable) good - lost on refresh, by design
# localStorage worst - any XSS reads it
Rotate refresh tokens and revoke the whole family on reuse - that is what converts theft from indefinite access into one extra request.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the JWT Authentication Course course, and every lesson in it is listed on the JWT Authentication Course contents page.