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_rtcookie — a missing body is legitimate in cookie mode, never a 400. InvalidandReuseDetectedare indistinguishable to the caller: the same 401invalid_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.