In the previous posts of this series I've been building a free .NET 10 Minimal API starter: JWT authentication, per-user ownership, rate limiting and health checks.
Until now, login returned a single JWT that lasted 60 minutes. That has two problems:
- It can't be revoked. If a token leaks, whoever has it keeps access for up to an hour, and "log out" only means the client forgets the token.
- Shortening it hurts users. A 15-minute token without a way to renew it means logging in again every 15 minutes.
Refresh tokens solve both. In this post I'll go through the design I chose, the code, how I tested expiry without waiting a week, and a couple of mistakes I made on the way.
The code is in the repo: Fmaes2/dotnet-minimal-api-starter.
The design in one table
Login now returns two tokens:
| Access token | Refresh token | |
|---|---|---|
| Format | JWT (signed, self-contained) | Opaque random string |
| Lifetime | 15 minutes | 7 days |
| Sent to | Every protected endpoint | Only /api/auth/refresh and /api/auth/logout
|
| Stored on the server | No | Yes, as a hash |
| Can be revoked | No (it just expires) | Yes |
{
"accessToken": "eyJhbGciOi...",
"accessTokenExpiresAt": "2026-10-24T15:45:00Z",
"refreshToken": "q3Zx...",
"refreshTokenExpiresAt": "2026-10-31T15:30:00Z"
}
The access token stays stateless, so protected endpoints don't hit the database to validate it. The refresh token is the stateful part, and it's only checked once every 15 minutes.
Three rules on top of that:
- Rotation: each refresh token can be used only once. Using it returns a new pair and invalidates the old token.
- Reuse detection: if an already used token shows up again, assume it was stolen and revoke the whole session.
- Revocation: logout revokes the session; logout-all revokes every session of the user.
I return the refresh token in the response body. For browser SPAs an HttpOnly cookie is the usual recommendation, but it brings CSRF and CORS into the picture, so I left it for a future post.
Storage: hash it, like a password (but not quite)
public class RefreshToken
{
public Guid Id { get; set; }
public required string UserId { get; set; }
public required string TokenHash { get; set; }
public Guid FamilyId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public Guid? ReplacedByTokenId { get; set; }
public bool IsActive(DateTime utcNow) => RevokedAt is null && ExpiresAt > utcNow;
}
A few decisions worth explaining:
Only the hash is stored. A refresh token is a credential. If the database leaks, plain tokens would let an attacker log in as any user for a week. With hashes, the leak is useless.
SHA-256, not bcrypt. Password hashes are slow on purpose because passwords have little entropy and can be brute-forced. A refresh token is 64 random bytes (512 bits), so there's nothing to guess, and a fast hash is enough. It also has a nice side effect: SHA-256 is deterministic, so the lookup is just WHERE TokenHash = @hash on a unique index.
var plain = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(64));
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(plain)));
FamilyId groups the tokens of one login. Every token issued by rotating inherits it. That's what makes "revoke the whole session" a single UPDATE.
DateTime, not DateTimeOffset. My integration tests use in-memory SQLite, and the EF Core SQLite provider can't translate DateTimeOffset comparisons to SQL. DateTime in UTC works on both SQLite and PostgreSQL (timestamptz), and Npgsql throws if a non-UTC DateTime sneaks in, so mistakes surface early.
Rotation and reuse detection
This is the core of the feature:
public async Task<IssuedRefreshToken?> RotateAsync(string token, CancellationToken ct)
{
var now = time.GetUtcNow().UtcDateTime;
var hash = Hash(token);
var stored = await db.RefreshTokens.AsNoTracking()
.SingleOrDefaultAsync(t => t.TokenHash == hash, ct);
if (stored is null)
return null;
if (stored.RevokedAt is not null)
{
// Someone is using a token that was already consumed: treat it as theft.
await RevokeFamilyAsync(stored.FamilyId, now, ct);
return null;
}
if (stored.ExpiresAt <= now)
return null;
var (plain, replacement) = NewToken(stored.UserId, stored.FamilyId, now);
await using var tx = await db.Database.BeginTransactionAsync(ct);
// Conditional update: only one concurrent request can claim the old token.
var claimed = await db.RefreshTokens
.Where(t => t.Id == stored.Id && t.RevokedAt == null)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.RevokedAt, now)
.SetProperty(t => t.ReplacedByTokenId, replacement.Id), ct);
if (claimed == 0)
return null;
db.RefreshTokens.Add(replacement);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return new IssuedRefreshToken(stored.UserId, plain, replacement.ExpiresAt);
}
Why reuse detection works. Imagine an attacker steals a refresh token. Now two parties have it: the real client and the attacker. Whoever uses it first gets a new pair. When the other one tries, the token is already revoked. The server can't tell which of the two is the attacker, so it revokes the whole family and both have to log in again. The real user is annoyed once; the attacker is locked out.
Why the conditional update. Reading the token and then marking it as used is a classic race: two requests can both read it as active and both get a new pair. WHERE Id = @id AND RevokedAt IS NULL lets the database decide: only one UPDATE affects a row, the other gets 0 and returns 401. It needs no row version column, so it behaves the same on PostgreSQL and SQLite.
Why the transaction. Revoking the old token and inserting the new one must happen together. Without it, a failure on the insert would leave the user with a revoked token and no replacement.
Why AsNoTracking. ExecuteUpdateAsync runs SQL directly and bypasses the change tracker, so a tracked copy of the token would be stale. Not tracking it avoids that trap.
The endpoints
group.MapPost("/refresh", Refresh)
.AddEndpointFilter<ValidationFilter<RefreshTokenRequest>>()
.RequireRateLimiting(RateLimitingExtensions.AuthPolicy);
group.MapPost("/logout", Logout)
.AddEndpointFilter<ValidationFilter<RefreshTokenRequest>>();
group.MapPost("/logout-all", LogoutAll).RequireAuthorization();
private static async Task<Results<Ok<AuthResponse>, UnauthorizedHttpResult>> Refresh(
RefreshTokenRequest req, UserManager<IdentityUser> users, TokenService tokens,
RefreshTokenService refreshTokens, CancellationToken ct)
{
var rotated = await refreshTokens.RotateAsync(req.RefreshToken, ct);
if (rotated is null)
return TypedResults.Unauthorized();
var user = await users.FindByIdAsync(rotated.UserId);
if (user is null)
return TypedResults.Unauthorized();
var (access, accessExpires) = tokens.CreateToken(user);
return TypedResults.Ok(new AuthResponse(access, accessExpires, rotated.Token, rotated.ExpiresAt));
}
Some details:
-
/refreshand/logoutare anonymous: by the time the client calls them, the access token has probably expired. The refresh token itself identifies the session. -
/refreshreloads the user instead of copying claims from the old token, so a changed email (or, later, roles) is picked up. -
/logoutalways returns204, even for unknown tokens. Different answers would let someone probe which tokens are valid. -
/refreshreuses the existingauthrate limiting policy. Logout doesn't need it: brute-forcing a logout gains nothing. - The validator caps the token at 200 characters (a real one is 86), so nobody can make the server hash megabytes of input.
Don't forget ClockSkew
TokenValidationParameters.ClockSkew defaults to 5 minutes: a JWT is still accepted up to 5 minutes after it expires, to tolerate clock differences between servers. With a 60-minute token nobody notices. With a 15-minute token, it's 33% of extra lifetime for a token you can't revoke.
ClockSkew = TimeSpan.FromSeconds(30)
Testing expiry without waiting 7 days
The service gets the time from an injected TimeProvider instead of DateTime.UtcNow:
builder.Services.TryAddSingleton(TimeProvider.System);
TryAdd only registers it if nothing else did, which leaves room for the tests to swap it. With the Microsoft.Extensions.TimeProvider.Testing package, the expiry test moves the clock forward instead of waiting:
[Fact]
public async Task Refresh_WithExpiredToken_Returns401()
{
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
using var app = factory.WithWebHostBuilder(b =>
b.ConfigureTestServices(s => s.AddSingleton<TimeProvider>(clock)));
var client = app.CreateClient();
var login = await RegisterAndLoginAsync(client);
clock.Advance(TimeSpan.FromDays(8));
var response = await RefreshAsync(client, login.RefreshToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
ConfigureTestServices runs after Program.cs, and when a service is registered twice the container resolves the last one, so the fake clock wins.
The other tests cover rotation, reuse revoking the whole family, unknown and empty tokens, logout and logout-all.
One case I deliberately didn't test: two concurrent refreshes with the same token. The test factory shares a single in-memory SQLite connection, and SqliteConnection isn't meant to be used from several threads at once. The test could fail because of the connection rather than my code, which is the definition of a flaky test. Testing it properly needs a real PostgreSQL instance, for example with Testcontainers.
Mistakes I made along the way
1. A table called RefreshToken instead of RefreshTokens. I configured the entity in OnModelCreating but forgot the DbSet. The code that used db.RefreshTokens didn't compile, and when I looked at the migration I'd already generated, the table name was singular. EF Core names a table after the DbSet property; without one, it uses the class name. Since the branch wasn't pushed yet, I added the DbSet, removed the migration, generated it again and amended the commit.
2. A 500 on login. The new code was running, but the migration was still pending in my local database because MigrateOnStartup is off in Development. The response only said "An unexpected error occurred" (on purpose, the global handler doesn't leak details), and the real cause was in the Serilog output. dotnet ef migrations list showed the migration as (Pending), and dotnet ef database update fixed it. I added this to the README's troubleshooting section.
Breaking change
The login response changed from { token, expiresAt } to { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt }, and the default access token lifetime went from 60 to 15 minutes. In Conventional Commits that's a feat!: with a BREAKING CHANGE: footer.
Known limitations
- Access tokens can't be revoked: after logout they stay valid until they expire (15 minutes at most).
- If a client fires two refreshes at once with the same token, one gets
401. Clients should keep a single refresh in flight. - Expired and revoked tokens aren't cleaned up from the database yet.
What's next
Roles and policies. One thing I already know will bite: with MapInboundClaims = false (see part 2), IsInRole won't find the roles unless RoleClaimType is set in TokenValidationParameters.
If you have ideas or questions, open a Discussion in the repo or leave a comment here.
Top comments (1)
The conditional UPDATE covers two requests that land together, but a second tab refreshing a second later reads RevokedAt as set and takes the theft branch, so the user is logged out everywhere. Auth0's rotation has a configurable reuse interval for exactly that case. Did you consider a few seconds of leeway before RevokeFamilyAsync? It doesn't weaken the theft check much.