Refresh Tokens and Token Revocation in ASP.NET Core: A Beginner Guide
Learn refresh tokens and token revocation in ASP.NET Core with simple words, diagrams, and code. Short access tokens, rotation, reuse detection, and safe logout.
A movie ticket and the re-entry stamp
Imagine you go to a cinema in your town to watch a movie. At the counter you show your ID and buy a ticket. The staff give you two things: a paper ticket for the hall, and a small ink stamp on your hand for re-entry.
The paper ticket is checked at the door. It is quick to read and the guard does not need to call anyone. But it is only good for this one show. If you step out for a snack and the show is long, the ticket might say the time is over.
The stamp on your hand is different. It is tied to you in the cinema's register. If your ticket time runs out, you go back to the counter, show your stamp, and they print you a fresh ticket without buying again. And if you misbehave, the manager can cross out your name in the register so your stamp stops working immediately.
In ASP.NET Core, the paper ticket is your access token (a JWT). The hand stamp is your refresh token. The access token is fast to check but short-lived. The refresh token lives in your database, so you can cancel it any time. That power to cancel is called token revocation.
This guide builds the whole system step by step, in plain words.
Why we need two tokens
A JWT access token is self-contained. The server can check it just by reading it and verifying the signature. No database call. That makes it very fast. But it has one weakness: you cannot un-issue it. Once signed, it is valid until the moment it expires.
So we face a trade-off:
| Token type | How long it lives | How it is checked | Can you cancel it fast? |
|---|---|---|---|
| Access token (JWT) | Very short (5-15 min) | Read and verify signature | No, only by expiry |
| Refresh token | Long (days to weeks) | Look it up in the database | Yes, set a flag |
The trick is to use both together. Keep the access token short so a stolen one dies quickly. Use the refresh token to get new access tokens quietly in the background, and keep that refresh token in the database where you control it.
The login and refresh flow
Let us walk through the journey one request at a time. First the user logs in. Then they use the API. When the access token expires, the client quietly refreshes.
The important idea: the user types their password only once, at login. After that, the client keeps itself logged in using refresh tokens. The user never sees this. It feels like one long smooth session.
The token lifecycle
Steps
Login
Issue access + refresh tokens
Use API
Send access token on each call
Refresh
Swap expired access via refresh token
Revoke
On logout, mark refresh token dead
Setting up JWT bearer authentication
Before refresh tokens make sense, the API must understand access tokens. ASP.NET Core uses the Microsoft.AspNetCore.Authentication.JwtBearer package for this. You tell it how to validate the signature, the issuer, the audience, and the expiry.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var jwtKey = builder.Configuration["Jwt:Key"]!;
var jwtIssuer = builder.Configuration["Jwt:Issuer"]!;
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true, // reject expired tokens
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtIssuer,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero // no extra grace time
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.Run();A couple of small but important details. ValidateLifetime = true makes the framework reject a JWT once its expiry time has passed. And ClockSkew = TimeSpan.Zero removes the default 5-minute grace window, so an expired token really is treated as expired right away. For short tokens, that grace window matters.
Storing refresh tokens in the database
The refresh token must live somewhere we control. A simple table does the job. Each row links a token to a user and records whether it is still alive.
public class RefreshToken
{
public int Id { get; set; }
public string Token { get; set; } = default!; // a random secret
public string UserId { get; set; } = default!;
public DateTime ExpiresAtUtc { get; set; }
public bool IsRevoked { get; set; }
public DateTime? UsedAtUtc { get; set; } // set when rotated
public string? ReplacedByToken { get; set; } // points to the next one
public bool IsActive =>
!IsRevoked && UsedAtUtc is null && ExpiresAtUtc > DateTime.UtcNow;
}Notice the ReplacedByToken field. When we rotate a token, the old row points to the new one. This creates a chain of tokens, like a family tree. Later this chain helps us catch thieves.
One safety rule: do not store the raw refresh token if you can avoid it. Treat it like a password. Many teams hash it (for example with SHA-256) and store only the hash, just like API keys. For this beginner guide we keep the field named Token, but in production you would store TokenHash.
Generating the tokens
When the user logs in, you create both tokens. The access token is a signed JWT. The refresh token is just a big random string that nobody can guess.
using System.Security.Cryptography;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Text;
public class TokenService
{
private readonly string _key;
private readonly string _issuer;
public TokenService(IConfiguration config)
{
_key = config["Jwt:Key"]!;
_issuer = config["Jwt:Issuer"]!;
}
public string CreateAccessToken(string userId, string email)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Email, email),
// jti = a unique id for this exact token, used for blacklisting
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var creds = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _issuer,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(10), // short!
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string CreateRefreshToken()
{
// 64 random bytes, base64 encoded. Practically impossible to guess.
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
}The access token carries a jti claim (a JWT ID). We will use this id later for instant revocation. The refresh token is pure randomness from RandomNumberGenerator, which is the safe way to make secrets in .NET. Never use Random for this; it is predictable.
Refresh token rotation: one use each
Here is the most important security idea in this whole guide: rotation. Every time a refresh token is exchanged, you do three things.
- Check the old token is still active.
- Mark the old token as used and point it to the new one.
- Hand back a brand new refresh token plus a new access token.
So each refresh token works exactly once. This is far safer than reusing the same token for weeks.
Why does this catch thieves? Imagine an attacker steals a refresh token. Both the real user and the attacker now hold a copy. Whoever uses it first gets a new token and the old one becomes "used". When the second person tries the same old token, the server sees it is already used. That should never happen for an honest client. So the server treats it as theft and revokes the entire family of tokens, logging everyone out. The real user logs in again with a password; the attacker is locked out.
The refresh endpoint
Now we put rotation into a real endpoint. The client sends its current refresh token, and we either give back a fresh pair or reject and clean up.
app.MapPost("/refresh", async (
RefreshRequest request,
AppDbContext db,
TokenService tokens) =>
{
var stored = await db.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken);
if (stored is null)
return Results.Unauthorized();
// Reuse detection: a used or revoked token was replayed.
if (!stored.IsActive)
{
// Revoke the whole family for this user — likely theft.
var family = await db.RefreshTokens
.Where(t => t.UserId == stored.UserId && !t.IsRevoked)
.ToListAsync();
family.ForEach(t => t.IsRevoked = true);
await db.SaveChangesAsync();
return Results.Unauthorized();
}
// Happy path: rotate.
var newRefresh = tokens.CreateRefreshToken();
stored.UsedAtUtc = DateTime.UtcNow;
stored.ReplacedByToken = newRefresh;
db.RefreshTokens.Add(new RefreshToken
{
Token = newRefresh,
UserId = stored.UserId,
ExpiresAtUtc = DateTime.UtcNow.AddDays(7),
});
await db.SaveChangesAsync();
var user = await db.Users.FindAsync(stored.UserId);
var access = tokens.CreateAccessToken(user!.Id, user.Email);
return Results.Ok(new { accessToken = access, refreshToken = newRefresh });
});
public record RefreshRequest(string RefreshToken);Read the middle block carefully. If the token is not active — meaning it was already used, revoked, or expired — we do not just say "no". We assume the worst and revoke the whole family. This is the reuse-detection step that turns a quiet rotation system into a theft alarm.
Token revocation: turning the switch off
Revocation is the act of killing a token before it would naturally expire. You need this for logout, for password changes, for "log me out of all devices", and for security incidents.
When to revoke
Steps
Logout
Revoke this device's refresh token
Password change
Revoke all of the user's tokens
Suspicious activity
Revoke family on reuse
Admin action
Disable user, revoke everything
For refresh tokens, revocation is easy because they live in your database. Logout simply marks the row as revoked.
app.MapPost("/logout", async (RefreshRequest request, AppDbContext db) =>
{
var stored = await db.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken);
if (stored is not null)
{
stored.IsRevoked = true;
await db.SaveChangesAsync();
}
return Results.Ok(new { message = "Logged out" });
}).RequireAuthorization();But what about the access token that is already out there? Because it is a self-contained JWT, marking the refresh token dead does not stop the current access token. It will keep working until it expires. For most apps, a 10-minute window is fine — that is the whole point of keeping access tokens short.
If you truly need instant cut-off (for example, banking), you add a small blacklist keyed by the jti claim. On revocation you add that jti to a fast store like Redis with an expiry equal to the token's remaining life. Then a tiny middleware checks every request against the blacklist. This adds one lookup per request, so use it only when you really need it.
| Need | Cost | What to use |
|---|---|---|
| Normal logout | Cheap | Revoke refresh token, let access token expire |
| Log out everywhere | Cheap | Revoke all refresh tokens for the user |
| Instant access-token kill | Adds a lookup per request | jti blacklist in Redis |
| Clean up old rows | Background job | Delete expired and revoked tokens nightly |
Where the client stores tokens
A perfect server design still fails if the client leaks the tokens. The golden rules are short:
- For browsers, keep the refresh token in a secure, HttpOnly cookie. JavaScript cannot read HttpOnly cookies, so a cross-site script cannot steal it.
- For mobile apps, use the platform secure storage: Keychain on iOS, Keystore on Android.
- Never use
localStoragefor refresh tokens. It is readable by any script on the page. - Always send tokens over HTTPS only. Never put a token in a URL, because URLs end up in logs and browser history.
Keeping the table clean
Refresh tokens pile up. Every login and every rotation adds a row. Old, expired, and revoked rows have no value, so remove them on a schedule with a background job.
public class TokenCleanupService : BackgroundService
{
private readonly IServiceProvider _services;
public TokenCleanupService(IServiceProvider services) => _services = services;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow;
var dead = db.RefreshTokens
.Where(t => t.ExpiresAtUtc < cutoff || t.IsRevoked);
db.RefreshTokens.RemoveRange(dead);
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromHours(24), ct);
}
}
}This small worker runs once a day, deletes dead tokens, and keeps the table small and fast. Register it with builder.Services.AddHostedService<TokenCleanupService>();.
Common mistakes to avoid
A few traps catch many beginners:
- Long access tokens. If your access token lives for a day, revocation barely works. Keep it to minutes.
- No rotation. Reusing the same refresh token for weeks removes your best theft signal.
- Storing raw refresh tokens. Hash them like passwords so a database leak does not hand over live sessions.
- Skipping the reuse check. Without it, a stolen token quietly works forever. The family-revoke step is what makes the system honest.
- Trusting the client clock. Always compare expiry against
DateTime.UtcNowon the server.
Quick recap
- An access token is a short-lived JWT, fast to check but impossible to cancel early. A refresh token is long-lived, stored in your database, and you can revoke it any time.
- The client logs in once, then uses the refresh token to silently get new access tokens. The user never notices.
- Rotation gives a new refresh token on every exchange and marks the old one as used, so each token works only once.
- Reuse detection catches theft: if an already-used token shows up again, revoke the whole family and force a fresh login.
- Revocation is easy for refresh tokens (set a flag). For instant access-token cut-off, add a
jtiblacklist in Redis, but only when you really need it. - Store refresh tokens in HttpOnly cookies or secure mobile storage, never in
localStorage, and always use HTTPS. - Run a background cleanup job to delete expired and revoked tokens.
References and further reading
- Configure JWT bearer authentication in ASP.NET Core — Microsoft Learn
- How to use refresh tokens in ASP.NET Core: a complete guide — Simple Talk (Red Gate)
- Using Refresh Tokens in ASP.NET Core Authentication — Code Maze
- How to Implement Refresh Tokens and Token Revocation in ASP.NET Core — Anton Martyniuk
- How to Use Refresh Tokens in ASP.NET Core APIs — codewithmukesh
Related Posts
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
How to Add JWT Authentication to SignalR Hubs in ASP.NET Core
A beginner-friendly guide to securing SignalR hubs with JWT tokens in ASP.NET Core, including the access_token query string trick and the [Authorize] attribute.
Integrate Keycloak with ASP.NET Core Using OAuth 2.0
A beginner-friendly guide to securing an ASP.NET Core API and web app with Keycloak using OAuth 2.0 and OpenID Connect, with diagrams, tables, and copy-paste code.
Building Secure APIs with Role-Based Access Control in ASP.NET Core
Learn role-based access control (RBAC) in ASP.NET Core. Add roles to JWT tokens, guard endpoints with policies, and return correct 401 and 403 codes, with diagrams and code.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.