/ blog/refresh-token-rotation
blog / refresh-token-rotation / overview.md

Refresh token rotation — how to detect theft in a single round-trip

Most JWT tutorials skip the hard part: what happens when a refresh token is stolen? Here's how to detect reuse, revoke session families, and do it in under 5ms.

The problem with long-lived refresh tokens

Most auth systems hand out refresh tokens that last 30–90 days. If one is stolen, the attacker has weeks of access and you have no way to detect it — until the legitimate user logs in again and finds their session invalid.

The fix is refresh token rotation with reuse detection, as described in RFC 6819 Section 5.2.2.2.

How rotation works

When a client uses a refresh token, the server:

  1. Validates the token
  2. Issues a new access token + new refresh token
  3. Invalidates the old refresh token

The critical insight: each refresh token is single-use.

Detecting theft

Here's where it gets interesting. Store refresh tokens in a tree structure:

  • Every token has a parent_id pointing to the token it was issued from
  • All tokens from the same login form a session_family

If a reused refresh token is presented (one that was already rotated):

  • The server sees it's already been used (status = revoked)
  • This means either the legitimate client is replaying (rare bug) or a thief is using a stolen token
  • Safe assumption: revoke the entire session family
-- Revoke all tokens in the same family
UPDATE refresh_tokens
SET status = 'revoked', revoked_at = NOW(), revoked_reason = 'family_reuse_detected'
WHERE session_family_id = $1;

The schema

CREATE TABLE refresh_tokens (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash  TEXT NOT NULL UNIQUE,   -- bcrypt hash, never plaintext
  user_id     UUID NOT NULL REFERENCES users(id),
  session_family_id UUID NOT NULL,
  parent_id   UUID REFERENCES refresh_tokens(id),
  status      TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','rotated','revoked')),
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  revoked_at  TIMESTAMPTZ,
  revoked_reason TEXT
);

CREATE INDEX idx_rt_family ON refresh_tokens(session_family_id);

Performance

The token validation path hits Redis first (hash of the token → user_id lookup). Postgres only gets queried on rotation. This keeps the hot path under 2ms.

What to do on detection

When you detect reuse, you have a few options:

  • Revoke the family (always do this)
  • Send a security alert email to the user
  • Log the IP and user-agent of the suspicious request for investigation

Most teams stop at family revocation. Going further with alerting converts a silent security event into an observable one — critical for incident response.

Tags

securityauthjwt
0
0