Refresh Tokens: Rotation, Reuse Detection, Revocation and Secure Server-Side Storage
A refresh token should be replaced on every use, and if a consumed token comes back, the whole token family of that sign-in should be revoked; RFC 9700 requires this or sender-constrained tokens for public clients. With EF Core and SQLite we build SHA-256 hashed storage, atomic consumption with ExecuteUpdate, reuse detection, the two-tab race, sliding and absolute lifetimes and signing out of all devices with real outputs.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.EntityFrameworkCore.Sqlite 10.0.14, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 and Microsoft.IdentityModel.JsonWebTokens 8.23.0.
Guess First
Ada's Angular application renews its 5-minute access token with a refresh token. One day one of her refresh tokens (R2) ends up in an error log and falls into an attacker's hands. Ada's application has already used R2 and received the third token (R3) in its place. Ten seconds later the attacker tries to refresh with R2. What happens to the attacker, and what happens to Ada?
login -> 200 refresh token 9vdyrvDL0s..., GET /orders -> 200
refresh R1 -> 200 new refresh token VkGJEUWFMf...
refresh R2 -> 200 new refresh token T1Dx_G_zfW...
refresh R2 -> 401 Reused
refresh R3 -> 401 Revoked
The attacker is rejected, but Ada is locked out too: R3 is now invalid. This is not a bug, it is the design itself. From "the same token arrived twice" the server cannot tell who is legitimate; all it knows is that a copy of the token is in circulation. So it revokes all tokens derived from that sign-in (the family) and forces Ada to sign in again. In post 3 we saw MapIdentityApi's refresh token return 200 twice for the same token; in this post we build the system that closes that gap: refresh tokens stored as hashes, rotation, reuse detection, concurrent requests, lifetimes and revocation.
Why Two Tokens?
At the end of the previous post we saw the biggest weakness of the JWT (JSON Web Token): a signed token is valid until it expires and cannot be revoked. The fix is to split the responsibility in two. The access token is short-lived (5 minutes in Bookstore) and APIs (Application Programming Interfaces) validate it without touching the database. The refresh token is long-lived, goes only to the token endpoint, and the server's record is checked on every use. The refresh token is the point of revocation: stop it and the user's access ends within 5 minutes at most.
RFC (Request for Comments) 9700, the OAuth 2.0 security BCP (Best Current Practice), requires one of two paths for refresh tokens issued to public clients such as browser and mobile apps: sender-constrained tokens (tokens bound to a key with DPoP (Demonstrating Proof of Possession) or mTLS (mutual Transport Layer Security)) or refresh token rotation. Sender-constrained tokens are in post 13 of the series; here we build rotation. The same section also recommends that refresh tokens expire after a period of inactivity.
Schema: Hashed Tokens and Families
public sealed class RefreshToken
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public Guid FamilyId { get; init; }
public required string UserId { get; init; }
public required string TokenHash { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset FamilyExpiresAt { get; init; }
public DateTimeOffset? ConsumedAt { get; set; }
public Guid? ReplacedById { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public string? RevokedReason { get; set; }
}
Three fields are the heart of the design. FamilyId ties together all tokens derived from one sign-in; login starts a new family, every rotation adds a new row to the same family. ConsumedAt records that the token has been used once. And TokenHash is not the token itself but its Secure Hash Algorithm 256 (SHA-256) digest:
private (string Token, RefreshToken Entity) Create(string userId, Guid familyId, DateTimeOffset now, DateTimeOffset familyExpiresAt)
{
// 256 random bits; only the hash is stored, the token is shown to the client once
string token = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32));
DateTimeOffset idleExpiry = now + _options.IdleLifetime;
var entity = new RefreshToken
{
FamilyId = familyId,
UserId = userId,
TokenHash = Hash(token),
CreatedAt = now,
ExpiresAt = idleExpiry < familyExpiresAt ? idleExpiry : familyExpiresAt,
FamilyExpiresAt = familyExpiresAt
};
return (token, entity);
}
private static string Hash(string token) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
A refresh token should be stored like a password: if a database backup leaks, the values inside must not be usable to sign in. Passwords need a slow hash (PBKDF2: Password-Based Key Derivation Function 2) and a salt because passwords are guessable. Here the value is 256 random bits; it can't be guessed, so a single SHA-256 is enough, and leaving out the salt makes it possible to look the token up through a single unique index. The resulting table:
CREATE TABLE "RefreshTokens" (
"Id" TEXT NOT NULL CONSTRAINT "PK_RefreshTokens" PRIMARY KEY,
"FamilyId" TEXT NOT NULL,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"ExpiresAt" TEXT NOT NULL,
"FamilyExpiresAt" TEXT NOT NULL,
"ConsumedAt" TEXT NULL,
"ReplacedById" TEXT NULL,
"RevokedAt" TEXT NULL,
"RevokedReason" TEXT NULL
);
CREATE INDEX "IX_RefreshTokens_FamilyId" ON "RefreshTokens" ("FamilyId");
CREATE UNIQUE INDEX "IX_RefreshTokens_TokenHash" ON "RefreshTokens" ("TokenHash");
CREATE INDEX "IX_RefreshTokens_UserId" ON "RefreshTokens" ("UserId");
Guid.CreateVersion7() (added in .NET 9) produces time-ordered ids; indexes grow by appending at the end, without the page splits random Guids cause. The lab uses SQLite; the Entity Framework Core (EF Core) mechanics of the schema were covered in the EF Core series.
Rotation: Atomic Consumption
public async Task<RefreshResult> RotateAsync(string presented)
{
DateTimeOffset now = time.GetUtcNow();
string hash = Hash(presented);
RefreshToken? current = await db.RefreshTokens.AsNoTracking()
.SingleOrDefaultAsync(t => t.TokenHash == hash);
if (current is null)
{
return new(RefreshStatus.Unknown);
}
if (current.RevokedAt is not null)
{
return new(RefreshStatus.Revoked);
}
if (current.ConsumedAt is not null)
{
return await HandleReuseAsync(current, now);
}
if (current.ExpiresAt <= now)
{
return new(RefreshStatus.Expired);
}
// Mark as consumed only if nobody else did it first (atomic compare-and-set)
int claimed = await db.RefreshTokens
.Where(t => t.Id == current.Id && t.ConsumedAt == null && t.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ConsumedAt, now));
if (claimed == 0)
{
return new(RefreshStatus.InProgress);
}
(string token, RefreshToken next) = Create(current.UserId, current.FamilyId, now, current.FamilyExpiresAt);
db.RefreshTokens.Add(next);
await db.SaveChangesAsync();
await db.RefreshTokens.Where(t => t.Id == current.Id)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ReplacedById, next.Id));
return new(RefreshStatus.Rotated, current.UserId, token);
}
The critical line is ExecuteUpdateAsync. Reading the token, setting ConsumedAt in C# and calling SaveChangesAsync lets two simultaneous requests both see "not consumed yet". A single conditional UPDATE with WHERE ConsumedAt IS NULL uses the database's own atomicity: only one of the two requests updates a row, the other gets 0. That is the same purpose as the concurrency token in the EF Core series' concurrency post; here we do it with a narrower condition. The order of the checks matters too: revoked first, then consumed, expiry last; a token from a revoked family should get "revoked", not "expired", so the logs tell the right story.
Reuse Detection: Revoking the Family

private async Task<RefreshResult> HandleReuseAsync(RefreshToken token, DateTimeOffset now)
{
// Two tabs refreshing at the same moment is not theft: give a short grace window
if (now - token.ConsumedAt!.Value <= _options.ReuseGrace)
{
return new(RefreshStatus.InProgress);
}
int revoked = await RevokeFamilyAsync(token.FamilyId, "reuse-detected", now);
logger.LogWarning("Refresh token reuse detected for {UserId}; family {FamilyId} revoked ({Count} tokens)",
token.UserId, token.FamilyId, revoked);
return new(RefreshStatus.Reused);
}
private Task<int> RevokeFamilyAsync(Guid familyId, string reason, DateTimeOffset now) =>
db.RefreshTokens.Where(t => t.FamilyId == familyId && t.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, now).SetProperty(t => t.RevokedReason, reason));
A consumed token coming back means one of two things: either the client sent the same token twice, or a copy of the token is in someone else's hands. We can't tell which, but in both cases the active token in the family is suspect too. RFC 9700 describes exactly this: the server revokes the active refresh token and forces the legitimate client to obtain a fresh authorization. The log of the opening scenario and the final state of the table:
warn: Bookstore.Auth.RefreshTokenService[0]
Refresh token reuse detected for ada@bookstore.test; family 01a1b9d1-107e-798f-a784-cf06a1f84e80 revoked (3 tokens)
hash|family|UserId|consumed|replaced|RevokedReason
e14d653f56bf|CF06A1F84E80|ada@bookstore.test|1|1|reuse-detected
d5eb20f36f0c|CF06A1F84E80|ada@bookstore.test|1|1|reuse-detected
0c8af6015cb2|CF06A1F84E80|ada@bookstore.test|0|0|reuse-detected
Three tokens in the same family: two consumed and replaced, the last revoked while active. The ReplacedById chain answers "what was issued after this token?" in an incident review. This warning is a security event; it should be wired into the security logging and alerting flow described in post 15 of the Web Security (OWASP Top 10) series.
The Two-Tab Race
The side effect of reuse detection is that it can throw out a legitimate user by mistake. Ada has two tabs open; both notice the expired access token at the same moment and try to renew with the same refresh token. In the lab I sent two parallel requests with the same token, then the losing tab tried again 3 seconds later:
== parallel refresh from two tabs
tab -> 409 refresh_in_progress
tab -> 200 rotated
refresh G1 -> 409 refresh_in_progress
refresh G2 -> 200 new refresh token pZUSCg_-oa...
The request that lost the compare-and-set got 409 and the family was not revoked. The retry 3 seconds later also got 409 because it was inside the grace window (ReuseGrace, 10 seconds); the winning tab's token (G2) keeps working. The window is a security trade-off: reuse of a stolen token within those 10 seconds isn't flagged as reuse, but the attacker doesn't get a new token either, only a 409. The real fix is on the client: collapsing concurrent 401s into a single refresh request in Angular (single-flight) and sharing it across tabs with BroadcastChannel. We write that in post 17 of the series; the window on the server is there to protect the user when the client can't get everything right.
Lifetime: Sliding and Absolute
public sealed class RefreshTokenOptions
{
public TimeSpan IdleLifetime { get; set; } = TimeSpan.FromDays(3);
public TimeSpan AbsoluteLifetime { get; set; } = TimeSpan.FromDays(14);
public TimeSpan ReuseGrace { get; set; } = TimeSpan.FromSeconds(10);
}
Each new token gets an ExpiresAt that moves forward by the idle lifetime but never past the family's absolute end. So a session unused for 3 days dies (RFC 9700's inactivity recommendation), and a session used every day lives at most 14 days. It is the same sliding and absolute split as the cookie in post 2. Running the lab with a 5-second idle and an 8-second absolute lifetime:
t= 4.2s refresh -> 200 rotated
t= 7.2s refresh -> 200 rotated
t= 9.3s refresh -> 401 Expired
idle 6s -> 401 Expired
Even though every refresh came less than 5 seconds apart, the session ended at the 9th second: the absolute limit was 8 seconds. The second session, which waited 6 seconds without any use, fell to the idle limit.
Logout and "Sign Out of All Devices"
Logout revokes the family of the refresh token the client holds. "Sign out of all devices" revokes all of the user's active families:
auth.MapPost("/logout-all", async (ClaimsPrincipal user, RefreshTokenService refreshTokens) =>
{
int families = await refreshTokens.RevokeAllForUserAsync(user.FindFirstValue("sub")!, "logout-all");
return TypedResults.Ok(new { revokedSessions = families });
}).RequireAuthorization();
logout-all -> 200 {'revokedSessions': 2}
refresh phone -> 401 Revoked
The access token on the phone works for at most 5 more minutes; the moment it can't refresh, the session ends. If that delay is unacceptable (for example the moment an employee is let go), APIs can check a short-lived revocation list (per jti or per user); the price is a cache lookup on every request. Password changes, password resets and changes to the 2FA (Two-Factor Authentication) setting should also call RevokeAllForUserAsync; RFC 9700 mentions automatic revocation on such security events as an optional measure, and in Bookstore we make it the default.
In Production
The table grows. Every refresh adds a row; an active user with a 5-minute access token can produce hundreds of rows a day. Delete rows whose lifetime and family lifetime have passed with a background job, but keep revoked families for a while for investigation. The unique index on TokenHash keeps lookups fast, and the FamilyId and UserId indexes keep bulk revocation fast.
Where to store the refresh token in the browser is a separate decision. A refresh token in localStorage can be read through any XSS (Cross-Site Scripting) hole on the page; reuse detection notices the theft but doesn't prevent it. That is why RFC 10017 (OAuth 2.0 for Browser-Based Applications) recommends the BFF (Backend for Frontend) architecture, which never gives tokens to the browser, as the most secure option; the topic of post 14 of the series. The threat model of storing tokens in the browser is in the upcoming Frontend Security series.
Measure: the number of reuse warnings, the share of 409 responses and the number of revoked families. A sudden rise in reuse is either a client bug (broken single-flight) or a real token leak; both need a look right away.
Common Pitfalls
First, storing refresh tokens in plain text. Anyone with access to a database backup can sign in as any user.
Second, doing rotation without reuse detection. If you only treat the old token as "invalid" and don't revoke the family, whoever stole the token keeps the session going as long as they act before the legitimate client, and nobody notices.
Third, consuming with read-modify-write. Two parallel requests get two new tokens from the same token and the family splits in two.
Fourth, shipping strict reuse detection without a grace window and without single-flight on the client. Users get thrown out at random and the team closes the issue as "refresh token is broken" and removes the protection.
Fifth, not putting an absolute limit on the refresh token. A sliding lifetime alone can keep a session alive forever, like the cookie in post 2.
A Small Experiment
★☆☆ In the lab, set ReuseGrace to zero and repeat the two-tab scenario. Which response do you get, and what happens to the family? Show the result from the table.
★★☆ Change Hash to compute an HMAC (Hash-based Message Authentication Code), that is HMAC-SHA256, with a key kept on the server instead of plain SHA-256. Write two paragraphs on what this changes in the database leak scenario and how you would rotate that key.
★★★ Add device context to the refresh token: at login, store a device public key sent by the client in the family, and require proof signed with that key on every refresh request. Explain how this is a simplified form of DPoP (RFC 9449) and which scenario it makes reuse detection unnecessary for.
We built the identity tokens carry and how to revoke it. In the next post we move to the side that issues these tokens: OAuth 2.0's roles, grant types and scopes, the current recommendations that came with RFC 9700, and how the OAuth 2.1 draft brings them together.