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:
- Validates the token
- Issues a new access token + new refresh token
- 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_idpointing 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
Related Blogs
Beyond JWTs: Designing a Stateful, High-Performance Session Architecture
Stateless JWTs are great until you need to instantly revoke a compromised session. Here's how to build a stateful, Redis-backed authentication system that handles 50k+ concurrent users with sub-millisecond validation.
Defending Against SSRF in Node.js Microservices
Server-Side Request Forgery is deadly. If your app fetches URLs provided by users, you are at risk. Here's how to lock down node-fetch and axios.
Secure File Uploads: Why Trusting the Extension is Dangerous
Validating file uploads by checking if the filename ends in '.jpg' is a massive security hole. Learn how to securely process user uploads in Node.js.