Articles · Tokens & Sessions

Refresh-token families

Try out the running example

Access tokens are short-lived by design, so the refresh token is the credential that actually matters — steal one and you can mint access tokens for a month. Sentinel’s defense is the rotating token family with reuse detection: a stolen-and-replayed refresh token doesn’t just fail, it burns the entire lineage down and raises an alarm.

The anatomy

A refresh token is opaque: srt_ + 32 random bytes, base64url. The server never stores it — only its SHA-256 hex digest, in a RefreshTokenRecord:

public sealed record RefreshTokenRecord(
    string TokenHash, Guid FamilyId, Guid SessionId, Guid SubjectId, Guid RealmId,
    Guid? OrganizationId, DateTimeOffset ExpiresAt)
{
    public bool Used { get; init; }
    public string? ClientId { get; init; }   // OIDC binding rides the family
}

FamilyId is the star. The first token issued at login founds a family; every rotation issues a new token in the same family. One login session = one lineage of single-use tokens.

Rotation

var result = await refreshTokens.RefreshAsync(presentedToken, lifetime);

switch (result.Outcome)
{
    case RefreshOutcome.Rotated:       // happy path: result.NewToken is live
    case RefreshOutcome.Invalid:       // unknown, expired, or revoked
    case RefreshOutcome.ReuseDetected: // the alarm case, below
}

The pivot is a single store call with an explicit contract: IRefreshTokenStore.TryMarkUsedAsync(tokenHash) must be an atomic check-and-set — it returns false if the token was already used, and the contract explicitly forbids a TOCTOU implementation. Two racing refreshes with the same token cannot both win; the in-memory store does a CAS loop, the ValKey adapter an atomic operation.

Unit tests pin the happy path (Issue_then_refresh_rotates_within_family: outcome Rotated, new token ≠ old, same family) and expiry (Expired_tokens_are_invalid).

Reuse: the tripwire

Now the interesting case. The token presented exists but is already marked used. There are only two explanations: a client bug replaying old state — or two parties holding the same token, which means theft. Sentinel doesn’t guess:

// Inside RefreshAsync, when TryMarkUsedAsync returns false:
await store.RevokeFamilyAsync(record.FamilyId);
events.Emit(new SentinelEvent("token.refresh_reuse_detected",
                              record.RealmId, now,
                              record.SubjectId, record.OrganizationId));
return new RefreshResult(RefreshOutcome.ReuseDetected, null, record);

Every token in the family dies — including the legitimate successor the attacker (or the victim) is currently holding. Whichever party was the thief, both are now logged out, and the victim’s next login is the detection signal. That deliberate cost — the victim re-authenticates — is what makes the scheme sound: there is no state in which a stolen refresh token quietly coexists with the real one.

Reusing_a_rotated_token_revokes_the_family_and_emits asserts the outcome, the single emitted event, and that the rotated successor is Invalid afterward. On the wire, the HTTP test Refresh_rotates_and_reuse_is_rejected shows both the replayed token and the previously-issued successor answering 401.

What the HTTP endpoint adds

POST /auth/refresh wraps the service with transport concerns:

  • The token arrives in the JSON body or the path-scoped sentinel_rt cookie — a missing body is legitimate in cookie mode, never a 400.
  • Invalid and ReuseDetected are indistinguishable to the caller: the same 401 invalid_refresh_token, the same cookie clearing. The distinction goes to the audit stream, not to the possibly-hostile client.
  • After a successful rotation the endpoint re-validates the world: the session must still be live and the user still active — otherwise the freshly rotated family is revoked on the spot. A suspended user can’t keep a session alive by refreshing fast.
  • Success touches the session (LastSeenAt), mints a fresh access token, and re-appends cookies in cookie mode.

Families, sessions, and the kill switches

The family is also the unit of deliberate revocation:

Action Effect
POST /auth/logout revokes the session + its refresh family, notifies OIDC back-channel logout
POST /auth/logout-all RevokeSubjectAsync — every family, every session
DELETE /profile/sessions/{id} remote logout of one device (ownership-checked; foreign ids 404 identically)
Password reset RevokeSubjectAsync — a reset is a credible compromise signal

Access tokens are not revocable individually — they age out within their 10-minute lifetime, which is the documented, bounded revocation lag. If that bound matters to an endpoint, check the session server-side.

Monitoring it

token.refresh_reuse_detected is precisely the event you want a pager on: it fires on actual credential replay, almost never on user error, and it carries subject, realm and org. Route it through the webhook dispatcher — signed, retried, dead-lettered — and your SOC has a high-signal tripwire for free. The token theft recipe covers the operational runbook; OIDC reuses the same family machinery for its refresh grants, where authorization-code replay additionally revokes the family it minted.