ASP.NET Core Identity with JWT answers one question on every login screen — who are you — but production authorization keeps asking three more: how long does the session last, who is allowed to do what, and how do you express permissions that are finer than an administrator flag. This article extends the base token setup with refresh token rotation, role-based authorization, and claims-based policies in .NET 10, so a Blazor Server UI and a REST API share one coherent authorization model.
Why ASP.NET Core Identity with JWT Needs Refresh Tokens
A JWT is signed and self-contained, which is exactly what makes it fast to validate, but that also means a stolen token is a valid credential until it expires. The industry answer is to keep access tokens short — often 15 to 60 minutes — and issue a separate, long-lived refresh token that is stored in the database and can be revoked. When the access token expires, the client exchanges the refresh token for a new pair.
Access tokens are the primary credential for every API call, so their lifetime determines how much damage a leak can do. Fifteen minutes is a common default for user-facing apps, while machine-to-machine flows sometimes stretch it further. Whatever you choose, keep the access token independent of the refresh token — one should never contain the other's secret.
Rotation makes this even safer. Every refresh invalidates the previous refresh token, so a replayed token is detected and the whole token family can be terminated. Combined with ASP.NET Core Identity user lockout, rotation gives you a way to cut off a compromised session without waiting for a short-lived JWT to expire naturally. That revocation story is the single biggest reason enterprise APIs pair JWTs with a database-backed refresh token rather than simply minting very long-lived access tokens.
Adding Refresh Tokens to ASP.NET Core Identity with JWT
Start with a RefreshToken entity linked to the Identity user, then add a refresh endpoint that validates the presented access token, checks the stored refresh token is still active, and issues a fresh pair atomically:
public sealed class RefreshToken
{
public Guid Id { get; set; }
public string UserId { get; set; } = "";
public string Token { get; set; } = "";
public DateTime ExpiresAt { get; set; }
public bool IsRevoked { get; set; }
public string? ReplacedByToken { get; set; }
}
app.MapPost("/api/auth/refresh", async (RefreshRequest req,
UserManager<AppUser> users, AppDbContext db, ITokenService tokens) =>
{
var principal = tokens.ValidateAccessToken(req.AccessToken);
var userId = principal?.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return Results.Unauthorized();
var stored = await db.RefreshTokens
.FirstOrDefaultAsync(t => t.UserId == userId
&& t.Token == req.RefreshToken
&& t.ExpiresAt > DateTime.UtcNow
&& !t.IsRevoked);
if (stored is null)
return Results.Unauthorized();
stored.IsRevoked = true;
stored.ReplacedByToken = req.RefreshToken;
var user = await users.FindByIdAsync(userId);
var access = tokens.BuildAccessToken(user);
var refresh = tokens.BuildRefreshToken(user.Id);
db.RefreshTokens.Add(refresh);
await db.SaveChangesAsync();
return Results.Ok(new { AccessToken = access, RefreshToken = refresh.Token });
});
Notice the checks: the stored token must exist, must not be expired, and must not already be revoked. Because rotation revokes the old token on every use, a stolen refresh token can be replayed at most once — and that replay immediately marks the family as suspicious. In a Blazor Server app you typically store the refresh token server-side and keep only the access token in the browser, which shrinks the attack surface considerably.
There is one implementation detail worth repeating: the exchange must be atomic. Mark the old token revoked, persist the new pair, and only then return it to the client. If two requests race with the same refresh token, the second one finds the token revoked and the session is cut off — exactly the behavior you want when an attacker and a legitimate user both hold a leaked credential.
Roles and Claims-Based Policies
Authentication says who you are; authorization says what you may do. ASP.NET Core Identity with JWT supports both styles. Roles are coarse buckets like Admin or Manager that you attach with UserManager.AddToRoleAsync. Claims carry arbitrary facts — permission, department, or tenant — and are embedded in the token at sign-in. Policies combine them into reusable rules:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdmin", p =>
p.RequireRole("Admin"));
options.AddPolicy("CanManageProducts", p =>
p.RequireClaim("permission", "products.manage"));
options.AddPolicy("TenantOnly", p =>
p.RequireClaim("tenant")
.RequireAuthenticatedUser());
});
app.MapPost("/api/products", Products.Create.Handle)
.RequireAuthorization("CanManageProducts");
app.MapGet("/api/reports", Reports.Run.Handle)
.RequireAuthorization("RequireAdmin");
Role and claim requirements compose cleanly. A policy can require two roles, a specific claim value, or even a custom IAuthorizationRequirement that inspects the database. The important habit is to put the claims inside the token at issuance time, because the API then enforces authorization without a database lookup on every request.
Applying Policies in Blazor Server and REST Endpoints
The same policies work on both sides of a .NET 10 application. Minimal API endpoints use RequireAuthorization("PolicyName"), while Razor components use the attribute form or the AuthorizeView component with a policy:
@attribute [Authorize(Policy = "RequireAdmin")]
<AuthorizeView Policy="CanManageProducts">
<Authorized>
<MudButton Variant="Variant.Filled">New Product</MudButton>
</Authorized>
</AuthorizeView>
- Keep access tokens short and put roles and claims inside them at issuance.
- Rotate and revoke refresh tokens in the database on every exchange.
- Define policies once in
AddAuthorizationand reuse them across UI and API. - Remember that SignalR hubs and Blazor Server circuits inherit the same authorization context.
This is the pattern inside Indotalent products: roles for module access, claims for tenant scoping in the SaaS editions, and refresh token rotation for long-lived sessions. The pieces are small, but together they turn a basic JWT setup into a production-grade security model.
Key Takeaways
- Short access tokens plus rotated refresh tokens limit the blast radius of a stolen credential
- Refresh tokens belong in the database so they can be revoked and replayed checks work
- Roles handle coarse buckets; claims and policies handle fine-grained, reusable permissions
- The same policies protect Minimal API endpoints and Blazor Server components
- Indotalent products combine these exact techniques with ASP.NET Core Identity in .NET 10
FAQ
How long should a JWT access token live? Fifteen to sixty minutes is a common range. Short lifetimes reduce the damage a stolen token can cause, and refresh tokens carry the cost of keeping the session alive.
What is the difference between a role and a claim? A role is a named group such as Admin, while a claim is an arbitrary attribute such as permission=products.manage or tenant=acme. Policies can combine both.
Where should the refresh token be stored in a Blazor Server app? Server-side storage (session or a protected database row) is safest, with only the short-lived access token handed to the client.
Do policies work for SignalR hubs? Yes. Decorate the hub or methods with [Authorize] and the same policy, and the connection is rejected before any handler runs.