Refresh token rotation with reuse detection, explained

Refresh token rotation with reuse detection: hashed single-use tokens, a family id that revokes on replay, and one client refresh for ten parallel 401s.

By

Refresh token rotation means every refresh hands out a new refresh token and retires the old one. Add reuse detection and a stolen token becomes a liability for whoever stole it: the moment two parties use the same token, the server notices and ends the session. I recently wrote down the backend auth standard my team's conventions are built from, and this post walks through it: the session rules shared by backend, web and mobile, the server side of rotation, the client side that keeps rotation from logging people out, and the password reset rules that sit next to it.

One session state machine, three branches

The rule that made everything else simpler: backend, web and the Flutter client share one session state machine, and it has three branches.

  1. A valid access token is used.
  2. An expired access token is refreshed, exactly once.
  3. A dead refresh token is a clean logout.

There is no fourth branch. In particular, a 403 never triggers a refresh. The user is authenticated and not allowed to do the thing, and a fresh token would get an identical 403.

The server returns distinct error codes so a client can tell the cases apart: "log in again", "account closed" and "another device signed in" each get their own code, and each produces a different message. Without them, every ending looks like a generic logout and support gets the questions.

What is refresh token rotation?

Refresh token rotation is issuing a new refresh token every time the old one is used, and invalidating the old one. Reuse detection is the second half: the server remembers which tokens belong together, so if a retired token is presented again, it knows the token was copied and revokes the active one too. The OAuth security best practice, RFC 9700, section 4.14, describes exactly this and notes the cost: the legitimate client has to sign in again, which stops the attacker.

The server side: hashed, single-use, grouped by family

Refresh tokens are rotating and single-use, and they are stored as hashes, so a database leak does not hand out live sessions. Each token carries a family_id shared by every token descended from the same login. When a token that was already used shows up again, the whole family is revoked:

CREATE TABLE refresh_tokens (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    uuid NOT NULL REFERENCES users(id),
  family_id  uuid NOT NULL,
  token_hash text NOT NULL UNIQUE,
  used_at    timestamptz,
  revoked_at timestamptz,
  expires_at timestamptz NOT NULL
);

-- A used token came back: someone holds a copy, so the newest token dies too.
UPDATE refresh_tokens SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL;

The names are simplified. The point of the family is the last line: revoking only the replayed token would leave the attacker's newer token alive, depending on who refreshed first.

The same standard defines the tables around it: users, the refresh tokens, and one-time codes. Passwords are hashed with bcrypt, codes with sha256, and each code has a purpose column, so a code issued to verify an email cannot be spent on a password reset. A resend_available_at column is the rate limit on sending new codes.

The client side: one refresh for ten 401s

Rotation punishes a careless client. If a screen fires ten requests and all ten get a 401, a naive interceptor sends ten refreshes. The first one rotates the token, and the other nine present a token that was just retired, which looks exactly like a replay. The client logs itself out.

So the client is single-flight: ten parallel 401s trigger one refresh, and every request waits for it. It retries each request once, behind a flag, skips the auth endpoints themselves, and goes straight to logout on the force-logout codes. In a TypeScript client with axios, the shape looks like this:

let refreshing: Promise<string> | null = null;

api.interceptors.response.use(undefined, async (error) => {
  const req = error.config;
  if (error.response?.status !== 401 || req._retried || req.url?.startsWith("/auth/")) {
    throw error;
  }
  req._retried = true;
  refreshing ??= refreshSession().finally(() => { refreshing = null; });
  req.headers.Authorization = `Bearer ${await refreshing}`;
  return api(req);
});

refreshSession is illustrative: it calls the refresh endpoint, stores both new tokens, and logs out on the force-logout codes. The 401 handling belongs to the session rule, so it lives in one place in the API client and nowhere else.

Only the refresh endpoint decides a token is dead

On a Flutter app I work on, one bad moment could log a user out for good, and the cause was the opposite mistake. App startup deleted the stored refresh token whenever resuming the session failed, and resuming failed for anything at all. A dropped connection, a request cut off by a hot restart, or an unguarded call to a billing SDK after the user profile had already loaded each cost the user a refresh token valid for 1000 days. With the token gone, the next launch showed the login screen too, so a single failure read as a permanent logout.

The fix was made at the layer where the failure paths converge. Only the refresh call can know a token is dead: it drops the token on a 4xx and keeps it on a timeout or a 5xx. Startup no longer deletes anything; on failure it starts logged out and tries again next launch. The billing SDK call is now wrapped, so once the profile loads, a third-party SDK cannot undo the session. And because diagnostics were silent in release builds, the failure had never left a trace. My default now is to check that auth failures reach the logs in a release build, before I need them.

Password reset, logout and one-time codes

The same standard covers the flows next to sessions, each with its reason:

  • Forgot-password always returns 200, for real and fake addresses alike, so it cannot be used to find out who has an account. The OWASP forgot password cheat sheet gives the same advice.
  • A reset revokes every refresh token, so the reset logs the attacker out.
  • Logout revokes on the server. Dropping the client's copy leaves a valid token wherever else it was stored.
  • Codes are 6 digits by default. An older app used 4, which is weaker and only survives because it shipped, so nothing new gets 4 unless a person decides it should.
  • A password-changed email is mandatory. A password change nobody is told about is how a takeover goes unnoticed.

How I verified it: 22 checks against a real Postgres

I tested the backend with 22 checks against a real Postgres. They covered: an email normalised from mixed case, a 6-digit code, a resend inside the cooldown returning 429 with retry_after_seconds, a correct code accepted once and rejected on replay, login blocked before verification, refresh rotating, a replayed refresh token killing the whole family so the newest token dies with it, logout revoking on the server, forgot-password responding identically for a real and a fake address, a reset revoking all sessions, and passwords stored as bcrypt and codes as sha256.

Writing the rules down first is what made that list easy to produce. I wrote about keeping team rules in a form agents can follow in machine-readable engineering conventions.

The short version

Rotate refresh tokens on every use, store them hashed, group them in families, and revoke the family when a used token comes back. Make the client refresh once for any number of parallel 401s, and let only the refresh endpoint decide a token is dead. Other posts on securing app traffic: a shared secret for in-app purchase webhooks and keeping test API keys out of production builds. If your auth needs this kind of review, see my services.

Want this kind of work on your product?

Get in touch