Febasidocs
Guides

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

CodeHTTPWhen
INVALID_TOKEN401Token does not exist, has been revoked, or has expired.
VALIDATION_ERROR400Body is missing refreshToken.
TENANT_NOT_FOUND404The 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.

On this page