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.
// 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.