Skip to main content
SEMastery
ASP.NETintermediate

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.

13 min readUpdated April 22, 2026

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 typeHow long it livesHow it is checkedCan you cancel it fast?
Access token (JWT)Very short (5-15 min)Read and verify signatureNo, only by expiry
Refresh tokenLong (days to weeks)Look it up in the databaseYes, 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.

Figure 1: Two tokens working together. The short access token guards the API. The long refresh token, stored in the database, mints new access tokens.

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.

Figure 2: Full login and refresh flow as a sequence between client, API, and database.

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

Login
Use API
Refresh
Revoke

Steps

1

Login

Issue access + refresh tokens

2

Use API

Send access token on each call

3

Refresh

Swap expired access via refresh token

4

Revoke

On logout, mark refresh token dead

From login to logout, each stage of a token's life.

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.

  1. Check the old token is still active.
  2. Mark the old token as used and point it to the new one.
  3. 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.

Figure 3: Rotation as a state machine. A token moves from active to used, and any reuse of a used token flips the whole family to revoked.

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

Logout
Password change
Suspicious activity
Admin action

Steps

1

Logout

Revoke this device's refresh token

2

Password change

Revoke all of the user's tokens

3

Suspicious activity

Revoke family on reuse

4

Admin action

Disable user, revoke everything

Events that should immediately cancel tokens.

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.

NeedCostWhat to use
Normal logoutCheapRevoke refresh token, let access token expire
Log out everywhereCheapRevoke all refresh tokens for the user
Instant access-token killAdds a lookup per requestjti blacklist in Redis
Clean up old rowsBackground jobDelete 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 localStorage for 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.UtcNow on 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 jti blacklist 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

Related Posts