Remigiusz ZalewskiRemigiusz Zalewski

JWT Authentication in ASP .NET Core with Refresh Token (.NET 9)

jwt-authentication-refresh-tokens-aspnet-core

Introduction

.NET 8's MapIdentityApi gives you /register, /login, and /refresh for free, but it also decides the shape of the tokens for you. This video takes the manual route on .NET 9: ASP.NET Core Identity still owns user and role storage, but the access token, the refresh token, and the rotation logic are all hand-written - useful the moment you need claims, expiry rules, or a storage strategy MapIdentityApi doesn't give you out of the box.

🎬 Watch the full video here:


Two tokens, two jobs

A short-lived access token (a signed JWT, typically 5-15 minutes) is sent with every API call and never touches a database on validation - the signature alone proves it's genuine. A long-lived refresh token (hours to days) does nothing but prove the user authenticated recently enough to be issued a new access token without re-entering a password.

var accessToken = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
    issuer: jwtOptions.Issuer,
    audience: jwtOptions.Audience,
    claims: await BuildClaimsAsync(user, userManager),
    expires: DateTime.UtcNow.AddMinutes(jwtOptions.AccessTokenMinutes),
    signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));

BuildClaimsAsync pulls the user's roles from Identity (await userManager.GetRolesAsync(user)) and writes each one as a ClaimTypes.Role claim, so [Authorize(Roles = "Admin")] works against the token without a database round trip per request.

Storing the refresh token

The refresh token itself is a random opaque string (a GUID or a cryptographically random byte array, not a JWT) persisted against the user row with an expiry:

public class RefreshToken
{
    public string Token { get; set; }
    public DateTime ExpiresAt { get; set; }
    public string UserId { get; set; }
    public bool IsRevoked { get; set; }
}

On the browser side it's set as an HttpOnly, Secure cookie rather than handed to JavaScript:

Response.Cookies.Append("refreshToken", refreshToken, new CookieOptions
{
    HttpOnly = true,
    Secure = true,
    SameSite = SameSiteMode.Strict,
    Expires = DateTimeOffset.UtcNow.AddDays(7)
});

HttpOnly means client-side JavaScript can never read it, which closes off the main way a cross-site scripting bug turns into a stolen session. The access token still lives in memory on the client and is attached to the Authorization header per request.

Refresh and rotate

/refresh reads the cookie, looks up the stored token, checks it hasn't expired or been revoked, and issues a new access token and a new refresh token - invalidating the old one:

var stored = await db.RefreshTokens.SingleOrDefaultAsync(t => t.Token == incomingToken);
if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
    return Results.Unauthorized();

stored.IsRevoked = true;
var newRefreshToken = await IssueRefreshTokenAsync(stored.UserId);

Rotating on every use (rather than reusing the same refresh token until it naturally expires) means a leaked-and-reused refresh token gets detected: if the same token is presented twice, the second caller is either the legitimate user after a network retry, or an attacker replaying a stolen token - and a real implementation should revoke the whole token family the moment that happens.

Common pitfalls

  • Storing the refresh token in localStorage. Defeats the purpose - it becomes readable by any script on the page.
  • Never rotating the refresh token. A leaked token then works for its entire lifetime with no way to detect misuse.
  • Putting too much in the access token. Keep it to claims you actually check; it's sent on every request.
  • No revocation path. A logout or a compromised-account report needs a way to invalidate outstanding refresh tokens immediately.
  • Skipping SameSite on the cookie. Without it, the cookie can be sent along with cross-site requests you didn't intend to authenticate.

Key Takeaways

  • Access tokens are short-lived JWTs validated by signature alone; refresh tokens are long-lived, opaque, and stored server-side.
  • Identity still owns users and roles; roles are copied into the JWT as claims so authorization checks don't hit the database.
  • Store the refresh token in an HttpOnly, Secure, SameSite cookie - never in localStorage.
  • Rotate the refresh token on every /refresh call so a replayed old token is detectable.
  • Give logout and account-compromise flows a way to revoke outstanding refresh tokens.

Get the Full Source Code

The complete runnable solution - the .NET 9 API with Identity-backed users and roles, the hand-rolled JWT issuance, and the cookie-based refresh flow - is available to Patreon supporters. If you want to run the rotation logic yourself instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources