Security recipes · 02
Token theft & reuse detection
- The threat
- Exfiltrate a refresh token (XSS, malware, logs, backups) and quietly mint access tokens for its whole lifetime.
- Sentinel's counter
- Every refresh rotates atomically; presenting a rotated token revokes the entire family and emits token.refresh_reuse_detected.
- Capability
- /docs/authentication/

The attack
Access tokens expire in minutes; refresh tokens are the prize. One lands in an attacker’s hands through XSS, a stealer on a laptop, a request log, a crash dump, or a database backup — and naive refresh-token handling gives them weeks of quiet access: they refresh in parallel with the victim, and nothing ever looks wrong, because every individual request they make is valid.
The defining property of the attack is symmetry: after the theft, the server sees two indistinguishable clients presenting the same credential. Any defense must break that symmetry.
How Sentinel counters it
Rotation makes every refresh token single-use. RefreshTokenService.RefreshAsync
marks the presented token used via IRefreshTokenStore.TryMarkUsedAsync — contractually
an atomic check-and-set (the interface documentation explicitly forbids TOCTOU
implementations) — and issues a successor in the same family (FamilyId, founded at
login). Two parties can hold copies of a token, but only one can ever spend it.
Reuse is treated as proof of compromise. When TryMarkUsedAsync returns false —
the token exists but was already spent — Sentinel does not shrug it off as a client
retry:
RevokeFamilyAsync(familyId)kills every token in the lineage, including the live successor. Whoever the thief was, both parties are now unauthenticated.token.refresh_reuse_detectedis emitted with subject, realm and org.- The caller sees the same 401
invalid_refresh_tokenas any garbage token — no oracle for the attacker to distinguish “burned” from “expired”.
The unit test Reusing_a_rotated_token_revokes_the_family_and_emits pins the outcome,
the single event, and the successor’s death; the HTTP test
Refresh_rotates_and_reuse_is_rejected confirms it end to end on the wire, including
that the previously rotated token also answers 401.
Storage never holds the secret. Tokens are srt_ + 256 random bits, and only their
SHA-256 digest is persisted — a leaked database or backup yields hashes, not spendable
tokens. The same applies to snt_ API keys and SCIM tokens.
The blast radius has hard edges. Post-rotation, the endpoint re-validates that the
session is live and the user active — a suspended account can’t refresh itself back to
life. Password reset revokes every family for the subject; logout-all and per-device
DELETE /profile/sessions/{id} give users their own kill switches. Access tokens
remain valid at most their 10-minute lifetime — that bounded lag is the documented
trade for stateless verification.
OIDC inherits all of it. Refresh tokens minted by the authorization server ride the
same family machinery, and authorization-code replay additionally revokes the family
the code minted (oidc.code_reuse) — code interception gets the same detonation
treatment.
What your app must still do
- Keep tokens out of JavaScript’s reach. Use cookie transport for browsers (httpOnly + the CSRF double-submit) so XSS can’t read a refresh token in the first place — rotation limits the damage of theft; it doesn’t prevent theft.
- Page on the event.
token.refresh_reuse_detectedis a near-zero-false-positive signal. Route it through a signed webhook to your SIEM; treat it as an active incident (the victim’s next login is your interview window). Notifying the user is your UX decision — Sentinel provides the mailer port, not the policy. - Expect the victim logout. Family revocation deliberately logs out the legitimate user. Your client should handle a 401 on refresh by returning to login gracefully — that’s the alarm working, not a bug to retry around.
- Don’t log request bodies on auth routes, and scrub tokens from crash reporting. Sentinel never logs token material; keep your host’s middleware to the same standard.