Refresh token rotation with theft detection in ASP.NET Core

JWT tutorials end where security begins. They show you how to issue an access token, wave at refresh tokens in the last paragraph, and leave you with a system where a single leaked token is a permanent skeleton key.

Reslug is a URL shortening SaaS, which means my auth system protects API keys, custom domains, and billing. Here is the refresh token design it runs in production: rotation on every use, hashed storage, and a chain structure that detects token theft and responds automatically.

The setup: short-lived access, guarded refresh

Access tokens are JWTs with a short lifetime, held in client memory only. Not localStorage, not sessionStorage. Memory. XSS that can read your storage cannot read a variable inside a closed-over module scope nearly as easily, and a short expiry caps the damage window either way.

The refresh token travels in an httpOnly cookie scoped to the refresh endpoint path, so JavaScript never sees it at all. It is long-lived, which makes it the crown jewel, which means everything below is about protecting it.

Rule one: the database never stores the token

Refresh tokens are credentials. Reslug stores them the way it stores passwords, hashed:

TokenHashSHA-256 of the token, indexed; this is what lookups use
ExpiresAtHard expiry
RevokedAtSet when rotated or explicitly revoked
ReplacedByTokenHashHash of the successor token; this builds the chain
CreatedByIpAudit trail

A database leak therefore leaks no usable refresh tokens. The plaintext exists exactly twice: in the client’s cookie, and for microseconds in server memory during a refresh call.

Rule two: every use burns the token

Rotation means a refresh token is single-use. Present it, get a new access token and a new refresh token, and the presented one is revoked in the same transaction. The old token’s row records which token replaced it, and that ReplacedByTokenHash pointer is what turns individual rows into a linked chain:

token 1 replaced by token 2, replaced by token 3, replaced by token 4.

At any moment, exactly one token in the chain is alive: the newest one. Every ancestor is revoked but retained, because the dead ancestors are not garbage. They are tripwires.

The tripwire: replay of a revoked token means theft

Walk through the attack. An attacker steals the current refresh token, through malware, a logged request, a compromised device. Two copies of the same token now exist. Whoever uses it first gets rotated to a fresh token. The other party, and you cannot know if that is the attacker or your legitimate user, is now holding a revoked token, and will eventually present it.

That presentation is the signal. A structurally valid token that is revoked and has a successor cannot occur in normal operation. It means two parties held the same token, which means theft. And since you cannot tell which party is which, the only safe response is to kill the entire chain:

public async Task<RefreshResult> RotateAsync(
    string presentedToken, string? ip, CancellationToken ct)
{
    var hash = TokenHasher.Hash(presentedToken);
    var token = await db.RefreshTokens
        .SingleOrDefaultAsync(t => t.TokenHash == hash, ct);

    if (token is null)
        return RefreshResult.Invalid();

    if (token.RevokedAt is not null)
    {
        // A burned token came back. Two parties held it. Kill everything.
        await RevokeChainAsync(token, ct);
        return RefreshResult.Compromised();
    }

    if (token.ExpiresAt <= DateTimeOffset.UtcNow)
        return RefreshResult.Expired();

    var next = CreateToken(token.UserId, ip);
    token.RevokedAt = DateTimeOffset.UtcNow;
    token.ReplacedByTokenHash = next.TokenHash;
    db.RefreshTokens.Add(next);
    await db.SaveChangesAsync(ct);

    return RefreshResult.Success(next);
}

RevokeChainAsync walks the ReplacedByTokenHash pointers forward from the replayed token and revokes every descendant, including the currently live one. Both the attacker and the legitimate user are logged out. The user re-authenticates with their password and continues. The attacker re-authenticates with nothing.

The worst case for the user is one unexpected login prompt. The worst case for the attacker is a session that dies the moment the real user’s client refreshes. That asymmetry is the entire point of the design.

The honest edge case: two tabs, one token

Rotation has a famous failure mode. Two browser tabs fire a refresh at the same moment with the same token. Tab one wins and rotates. Tab two arrives microseconds later holding a token that is now revoked, and trips the theft detector. Your most loyal power user, the one with twelve tabs open, gets logged out by your security feature.

Known mitigations exist: a short grace window where the previous token is still accepted, or serializing refreshes client-side through a shared lock. The grace window softens the exact guarantee that makes the tripwire trustworthy, so I went the other way: the frontend deduplicates refresh calls behind a single in-flight promise, and the server stays strict. If the tripwire fires, someone genuinely presented a burned token.

Decide this tradeoff consciously for your own product. A strict server with a disciplined client is my answer, not the only answer.

Details that round it out

Sign-in requires a confirmed email, and five failed attempts lock the account for fifteen minutes, so the password behind the whole recovery path is itself rate-limited. Refresh token lookups hit an index on the hash column, because this runs on every session extension. CreatedByIp is stored for audit, not for enforcement, since legitimate users hop networks constantly. And explicit logout revokes the presented token immediately, so an abandoned session is dead rather than merely idle.

None of this is exotic. Every piece is a table column, an index, and a transaction. The distance between tutorial auth and production auth is not new technology, it is deciding that a leaked token must have an expiry on its usefulness, and building the three rules that guarantee it: never store plaintext, burn on use, and treat a burned token’s return as the attack it is.

I am building Reslug in the open as a production .NET case study. The auth system described here guards every account at https://reslug.com today.


Enjoyed this post? Subscribe to my YouTube channel for more great content. Your support is much appreciated. Thank you!


Check out my Udemy profile for more great content and exclusive learning resources! Thank you for your support.
Ervis Trupja - Udemy



Enjoyed this blog post? Share it with your friends and help spread the word! Don't keep all this knowledge to yourself.

Scroll to Top