Skip to main content

Refresh-token reuse detection (M33-004)

A refresh token may be exchanged exactly once. Presenting one that has already been spent means two parties hold it — so the platform revokes every token descended from that login and records a REFRESH_TOKEN_REUSE security event.

  • Owned by: LocalAuthService (accesscontrol engine), UserRefreshToken, RefreshTokenState.
  • Applies to both rotating endpoints: POST /api/v1/auth/refresh and POST /api/v1/auth/session/scope. Both hand out a new refresh token, so both are replay surfaces.
  • Always on. There is nothing to configure.
  • Client impact: none beyond the 401 clients already handle by returning to login.

The attack

Rotation on its own does less than it looks like it does. Suppose a token leaks — an XSS exfiltration, a proxy that logged a request body, a stolen laptop:

  1. The user holds A. The thief copies A.
  2. The user refreshes. A is spent, B comes back, and the user carries on.
  3. The thief presents A.

Before this ticket, step 3 rejected that one request and revoked that one row. B — the token the user is holding — kept working, and so did every token after it. The thief simply waits and tries again, or, if they got there first, holds a live branch of the session indefinitely while the user notices nothing.

Why the whole family, not just the token

Which of the two presenters is the attacker is unknowable from the server. Both hold a token that was legitimately issued; the only fact available is that a token exists in two places, which it never should. So the platform stops trying to tell them apart and ends both branches.

That is what makes theft self-limiting rather than indefinite. The moment the legitimate user refreshes, the thief's branch dies. The moment the thief refreshes, the user's does. Either way somebody has to sign in again, and an event says why — which is the only version of this where anybody finds out.

How the family is tracked

user_refresh_token.token_family_id is set once at login and carried unchanged through every rotation, so one indexed lookup reaches the whole chain.

The alternative — walking replaced_by_token_hash from token to token — is N queries and, more to the point, only as reliable as the weakest write to that column. The bug this ticket fixed proves it: revokeToken(current, null) set replaced_by_token_hash unconditionally, so detecting a replay nulled the very link the forensics needed.

Token state

user_refresh_token.token_state is a closed vocabulary (RefreshTokenState), CHECK-constrained in the database. revoked_at records when a token stopped working but never why, and the difference between "spent normally" and "replayed by somebody who should not have had it" is the distinction the whole check turns on.

StateMeaning
ACTIVEIssued, not yet spent. The only state a refresh may be exchanged from.
ROTATEDExchanged for a successor in the same family. Normal.
REVOKEDEnded without a successor — logout, logout-all, expiry, or a family sweep.
REUSEDPresented after it was already spent. The token that triggered the sweep.

Expiry is deliberately not a state: it is a fact about expires_at that needs no write, and giving it one would mean either a sweeper job or a column that lies between the expiry instant and the next write.

A family sweep does not rewrite an already-ended token's state, so a session's history still shows which of its tokens rotated normally and which one came back.

What an expired token does not do

An expired but never-spent token is refused, and that is all. No family revocation, no security event.

That is not leniency. It is a tab somebody left open over the weekend — the most ordinary thing a user can do — and an alert that fires on ordinary behaviour is one everybody learns to ignore, which costs you the alert that matters. The two conditions are checked separately and in that order: revoked_at/token_state says spent, expires_at says expired.

What is recorded, and what never is

The event goes on the existing M10-003 security_event trail — REFRESH_TOKEN_REUSE, reason category REFRESH_TOKEN_REUSE, outcome FAILURE — carrying:

  • the account (actor_summary) and its tenant,
  • the revoked family id and how many tokens in it were still live,
  • the request id.

It never carries the token, its hash, or any part of either. This row exists precisely because a credential is loose; writing the credential into the alert about the credential would be the worst available outcome. A family id is a random UUID that grants nothing. The same rule holds for the log line and for the 401 body, which says only Invalid refresh token.

Operational notes

  • A burst of these events from one account is an incident, not noise. It means a token is being presented from two places.
  • Existing rows at migration time each became their own family (accesscontrol/031). Their real chains survived only in replaced_by_token_hash, which the old reuse path overwrote with null, so grouping on it would have merged unrelated sessions. Tokens issued since are grouped properly.
  • audit/005 widens the TERM-008E reason_category CHECK for the new category. Without it the insert would fail and SecurityEventRecorderImpl — which swallows recording failures by design so they never break the caller — would have made the alert vanish silently.