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.

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_detected is emitted with subject, realm and org.
  • The caller sees the same 401 invalid_refresh_token as 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_detected is 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.