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(accesscontrolengine),UserRefreshToken,RefreshTokenState. - Applies to both rotating endpoints:
POST /api/v1/auth/refreshandPOST /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
401clients 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:
- The user holds
A. The thief copiesA. - The user refreshes.
Ais spent,Bcomes back, and the user carries on. - 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.
| State | Meaning |
|---|---|
ACTIVE | Issued, not yet spent. The only state a refresh may be exchanged from. |
ROTATED | Exchanged for a successor in the same family. Normal. |
REVOKED | Ended without a successor — logout, logout-all, expiry, or a family sweep. |
REUSED | Presented 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 inreplaced_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/005widens the TERM-008Ereason_categoryCHECK for the new category. Without it the insert would fail andSecurityEventRecorderImpl— which swallows recording failures by design so they never break the caller — would have made the alert vanish silently.
Related
- Access control — the engine that owns local login and the security log.
- Authentication rate limiting — the other control on this surface; it throttles guessing, this one handles a token that has already leaked.
- Password reset issues a one-time link — the same single-use-token discipline on the reset flow.