SF

ASP.NET Core Identity · JWT · Security

The session you thought was closed

A replayed refresh token, a session still valid after a password change. Atomic rotation, reuse detection, cascading revocation.

1
use per refresh token
CAS
atomic replacement
0
sessions surviving a replay

The problem

A refresh token is long-lived by design: that is what spares the user from signing in every fifteen minutes. But the same longevity makes it the most attractive target in an application. If it leaks — a browser extension, a shared machine, a badly configured log — the attacker holds indefinitely renewable access. Worse: by default, changing a password invalidates nothing. A user who suspects compromise performs the one action they were taught, and the attacker's session carries on living.

The approach

The token becomes single-use: each refresh issues a new one and invalidates the old. Replacement happens through compare-and-swap rather than a plain UPDATE — if two concurrent requests present the same token, the database picks a winner and the loser is rejected, instead of both walking away with a valid session. Before rotating, the hash of the consumed token is kept: presenting that token again is no longer a possible mistake, it is the signature of a replay. The right answer then is not to refuse the request but to revoke the user's entire token family, since there is no way to know who — the legitimate holder or the attacker — still has what. In parallel, password changes and role edits reset the security stamp, invalidating tokens already in circulation.

Code excerptRefreshTokenService.cs
// A refresh token is single-use. Presenting one that was already
// consumed is not a mistake — it means someone is replaying a token they
// should no longer hold, so the correct answer is to burn the whole family.
var presentedHash = RefreshTokenHasher.Hash(presentedToken);

var userId = await userRepository.FindUserIdByAuthenticationTokenAsync(
    LoginProvider, RefreshTokenName, presentedToken, cancellationToken);

if (userId is null)
{
    // Unknown token: either forged, or a replay of one already rotated.
    await HandlePossibleRefreshTokenReuseAsync(presentedHash, cancellationToken);
    throw new UnauthorizedAccessException("Invalid or expired refresh token");
}

// Remember what was consumed, before rotating — otherwise a later replay
// of this exact token is indistinguishable from a forgery.
await authenticationService.SetAuthenticationTokenAsync(
    user, LoginProvider, PreviousRefreshTokenHashName, presentedHash, cancellationToken);

// Compare-and-swap, not a plain update. Two refreshes racing on the same
// token must not both succeed: the database decides the winner, and the
// loser is rejected rather than silently issued a second valid session.
var rotated = await userRepository.TryReplaceAuthenticationTokenValueAsync(
    user.Id,
    LoginProvider,
    RefreshTokenName,
    expectedValue: presentedToken,
    newValue: newRefreshToken,
    cancellationToken);

if (!rotated)
{
    // Lost the race, or already rotated elsewhere. Fail closed.
    throw new UnauthorizedAccessException("Refresh token already rotated");
}

The result

A stolen token is usable exactly once, and that single use triggers revocation of the legitimate session — the user is signed out, which is precisely the signal they need. Changing a password genuinely closes open sessions. And roles removed from a user take effect immediately, without waiting for the token to expire on its own.

Compliance · CQRS · MapperlyGetting off MediatR and AutoMapper, in productionNext.js · Payload CMS · NeonSolo SaaS: from Payload CMS to a real client