Refresh tokens & rotation
Keep an access token alive — without ever leaving a stale token usable.
Refresh tokens are how a client stays logged in past the 15-minute access-token window. They are opaque UUIDs (not JWTs), they live in PostgreSQL, and they rotate on every use.
When to refresh
A client should call /refresh when its access token is about to expire. Two
common patterns:
- Eagerly — a few seconds before
exp, in a background timer. - Reactively — on the first request that returns
TOKEN_EXPIRED.
Either works. Reactive is simpler to implement; eager keeps the user from ever seeing a 401.
The refresh request
POST /api/v1/refresh
Content-Type: application/json{ "refreshToken": "8c9a3a48-7b9d-..." }There is no Authorization header — the refresh token is the credential.
What the server does
Look up the token
The token is hashed (SHA-256) and looked up in refresh_tokens. If missing,
revoked, or expired, the response is INVALID_TOKEN (401).
Lock the row
SELECT FOR UPDATE so two concurrent refreshes can't both succeed and create
a duplicate.
Mint the replacement
Inside the same transaction:
UPDATE refresh_tokens
SET revoked_at = NOW(), revoke_reason = 'TOKEN_ROTATION'
WHERE id = $1;
INSERT INTO refresh_tokens (id, user_id, tenant_id, token_hash, expires_at, ...)
VALUES (...);Then a new access token is issued from the freshest user state (so any new roles or permissions are picked up).
Return both new tokens
{
"success": true,
"data": {
"accessToken": "<new-jwt>",
"refreshToken": "<new-uuid>",
"expiresIn": 900,
"user": { "...": "..." }
}
}Why rotation matters
If a refresh token is leaked, the attacker gets exactly one use out of it — the moment the legitimate client refreshes, the leaked token is revoked. This is the same property classic banking apps rely on.
Failure modes
| Code | HTTP | When |
|---|---|---|
INVALID_TOKEN | 401 | Token does not exist, has been revoked, or has expired. |
VALIDATION_ERROR | 400 | Body is missing refreshToken. |
TENANT_NOT_FOUND | 404 | The tenant linked to the token has been deleted or deactivated. |
If you ever see INVALID_TOKEN on a token you just received, the most
likely cause is that a second client rotated the same token first —
which is the exact pattern you want to detect on a stolen token.
Sessions and rotation
Rotation is a per-token concern. Session limits are a per-user concern. They combine like this:
- A refresh never adds a session — it replaces one.
- A login can add a session, and may evict the oldest one if the limit is hit. See Sessions for the full atomic flow.
So a user with maxConcurrentSessions = 5 and 5 active devices can rotate
their tokens forever. Logging in from a 6th device will revoke the oldest.
Implementation tips
Single-flight refreshes
If multiple in-flight requests all detect a 401, gate the refresh with a
promise/mutex so only one network call goes out. Otherwise you'll race
yourselves and one of them will fail with INVALID_TOKEN.
Replace storage atomically
The moment /refresh returns the new token, write it to storage before
you let any new request use it. Losing the new token mid-rotation
means the user is logged out.
Logging out clears both
POST /logout revokes only the refresh token; the access token still
works until exp. Clear both client-side at the same time.