I've spent about four and a half years building ASP.NET Core APIs, and JWT auth is still the place I see the most "it works in Postman" security holes. Short-lived access tokens are easy. Refresh tokens that survive a phone reboot and a stolen laptop are harder.
This is the pattern I ship: a short JWT access token, an opaque refresh token stored as a SHA-256 hash, rotation on every refresh, and family-wide revocation when a rotated token shows up again. The code below comes from a .NET 10 / EF Core starter I maintain. It also runs on .NET 8 with almost no changes.
What each token is for
| Token | Lifetime I use | Form | Stored how |
|---|---|---|---|
| Access | 15 minutes | Signed JWT | Nowhere (stateless) |
| Refresh | 7 days | Opaque random bytes | SHA-256 hash in SQL |
The access token is what every [Authorize] endpoint checks. The refresh token is only sent to /api/auth/refresh (and revoke). I never put the refresh token inside the JWT, and I never store the raw refresh value in the database.
Options and JwtBearer setup
Keep lifetimes and the signing key in typed options, validated on startup:
public sealed class JwtOptions { public const string SectionName = "Jwt"; [Required] public string Issuer { get; set; } = ""; [Required] public string Audience { get; set; } = ""; /// <summary>HMAC-SHA256 key. Min 32 chars. User-secrets / env, never git.</summary> [Required, MinLength(32)] public string SigningKey { get; set; } = ""; [Range(1, 1440)] public int AccessTokenMinutes { get; set; } = 15; [Range(1, 365)] public int RefreshTokenDays { get; set; } = 7; } Wire JwtBearer with a tight clock skew and MapInboundClaims = false so claim names stay as you issued them:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.MapInboundClaims = false; o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwt.Issuer, ValidateAudience = true, ValidAudience = jwt.Audience, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwt.SigningKey)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), NameClaimType = "name", RoleClaimType = ClaimTypes.Role, }; }); Bug to avoid: leaving the default 5-minute ClockSkew. Combined with a 15-minute access token, a revoked-in-spirit token can still work for almost a third of its life after expiry. Thirty seconds is enough for clock drift.
Issue the access JWT
I put signing in a small TokenService. Access tokens get a unique jti every time so logs and future denylists have something to hang on:
public AccessToken CreateAccessToken(User user) { var now = clock.GetUtcNow().UtcDateTime; var expires = now.AddMinutes(_options.AccessTokenMinutes); var claims = new List<Claim> { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Email, user.Email), new(JwtRegisteredClaimNames.Name, user.FullName), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), new(ClaimTypes.Role, user.Role), }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); var token = new JwtSecurityToken( issuer: _options.Issuer, audience: _options.Audience, claims: claims, notBefore: now, expires: expires, signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); return new AccessToken( new JwtSecurityTokenHandler().WriteToken(token), expires); } Use TimeProvider instead of DateTime.UtcNow so unit tests can freeze time. That alone has caught more expiry bugs for me than any other change.
Refresh tokens: opaque, hashed, rotated
Refresh tokens are not JWTs. They're random bytes the client holds; the server only ever stores a hash.
public string CreateRefreshToken() => Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64)); public string HashRefreshToken(string refreshToken) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))); The entity looks like this:
public class RefreshToken : BaseEntity { public Guid UserId { get; set; } public string TokenHash { get; set; } = ""; public DateTime ExpiresAtUtc { get; set; } public DateTime? RevokedAtUtc { get; set; } public string? ReplacedByTokenHash { get; set; } public bool IsActive(DateTime nowUtc) => RevokedAtUtc is null && ExpiresAtUtc > nowUtc; } Unique index on TokenHash. Raw value goes to the client once in the login/register/refresh response and is never written to disk.
When I issue a new pair, I rotate: revoke the old row and point it at the replacement hash.
private async Task<AuthResponse> IssueTokensAsync( User user, CancellationToken ct, RefreshToken? replacing = null) { var now = clock.GetUtcNow().UtcDateTime; var access = tokens.CreateAccessToken(user); var rawRefresh = tokens.CreateRefreshToken(); var refresh = new RefreshToken { UserId = user.Id, TokenHash = tokens.HashRefreshToken(rawRefresh), ExpiresAtUtc = now.AddDays(_jwt.RefreshTokenDays), }; if (replacing is not null) { replacing.RevokedAtUtc = now; replacing.ReplacedByTokenHash = refresh.TokenHash; } db.RefreshTokens.Add(refresh); await db.SaveChangesAsync(ct); return new AuthResponse( access.Token, access.ExpiresAtUtc, rawRefresh, refresh.ExpiresAtUtc, UserDto.FromEntity(user)); } Refresh endpoint: rotation + reuse detection
This is the part most tutorials skip. If a revoked refresh token is presented again, treat it as theft: someone (or something) still has an old copy. Kill every active refresh token for that user.
public async Task<AuthResponse> RefreshAsync( RefreshTokenRequest request, CancellationToken ct = default) { var now = clock.GetUtcNow().UtcDateTime; var hash = tokens.HashRefreshToken(request.RefreshToken); var existing = await db.RefreshTokens.Include(t => t.User) .FirstOrDefaultAsync(t => t.TokenHash == hash, ct) ?? throw new UnauthorizedException("Invalid refresh token."); if (existing.RevokedAtUtc is not null) { // Re-use of a rotated token: likely theft. Revoke the whole family. var active = await db.RefreshTokens .Where(t => t.UserId == existing.UserId && t.RevokedAtUtc == null) .ToListAsync(ct); foreach (var token in active) token.RevokedAtUtc = now; await db.SaveChangesAsync(ct); throw new UnauthorizedException("Refresh token has been revoked."); } if (!existing.IsActive(now)) throw new UnauthorizedException("Refresh token has expired."); return await IssueTokensAsync(existing.User, ct, existing); } Logout is the quiet twin: hash, mark revoked if still active, return 204 either way. Don't leak whether the token existed.
public async Task RevokeAsync(RefreshTokenRequest request, CancellationToken ct = default) { var hash = tokens.HashRefreshToken(request.RefreshToken); var token = await db.RefreshTokens .FirstOrDefaultAsync(t => t.TokenHash == hash, ct); if (token is { RevokedAtUtc: null }) { token.RevokedAtUtc = clock.GetUtcNow().UtcDateTime; await db.SaveChangesAsync(ct); } } Controller surface is thin: rate-limit register/login/refresh, require [Authorize] on revoke and /me.
[HttpPost("refresh")] [AllowAnonymous] [EnableRateLimiting("auth")] public async Task<ActionResult<AuthResponse>> Refresh( RefreshTokenRequest request, CancellationToken ct) => Ok(await auth.RefreshAsync(request, ct)); [HttpPost("revoke")] [Authorize] public async Task<IActionResult> Revoke( RefreshTokenRequest request, CancellationToken ct) { await auth.RevokeAsync(request, ct); return NoContent(); } Bugs I keep fixing in other people's APIs
- Storing the raw refresh token. A DB dump then becomes a session dump. Hash it (SHA-256 is fine here; you're looking up a high-entropy secret, not a password).
- No rotation. One long-lived refresh token for weeks means a single XSS or log leak is a permanent login. Rotate on every refresh.
- No reuse detection. Rotation without killing the family on reuse just tells an attacker "try the old one once, then use the new one you already stole."
- Putting refresh tokens in
localStorageon a SPA and also reflecting them into logs. Prefer httpOnly Secure cookies for browser apps, or at least never log Authorization / refresh bodies. - Using the access JWT as a refresh token. Long-lived JWTs can't be revoked without a denylist. Keep access short; put revocation on the refresh table.
- Symmetric key in
appsettings.jsoncommitted to git. Min 32 chars, user-secrets locally, Key Vault / env in prod,ValidateOnStartso a missing key fails at boot. - Forgetting rate limits on
/loginand/refresh. Credential stuffing and refresh hammering are free without them. I use a fixed window per IP (and configure Forwarded Headers behind a proxy). -
MapInboundClaimsleft at default. Thensubbecomeshttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifierand yourUser.FindFirst("sub")returns null. SetMapInboundClaims = falseand pick claim types deliberately.
Client shape (quick)
- Access token:
Authorization: Bearer …on API calls. - On 401 from a protected endpoint: call
/api/auth/refreshonce with the refresh token, retry the request, and if refresh also 401s, force login. - On logout: call
/api/auth/revoke, then drop both tokens locally. - Never send the refresh token to any other endpoint.
The short version
- Access JWT ~15 minutes, refresh opaque ~7 days.
- Store only a SHA-256 of the refresh token; unique index on the hash.
- Rotate on every refresh; link old → new via
ReplacedByTokenHash. - Reuse of a revoked refresh token revokes every active token for that user.
- Tight clock skew, typed Jwt options, rate limits on auth endpoints, secrets out of git.
I packaged this flow (plus roles, ProblemDetails, paging, Serilog, Docker and tests) as a .NET 10 API starter. If you'd rather have someone wire rotation and reuse detection into an existing ASP.NET Core API, I also take small auth fix-it jobs on Fiverr.
What refresh-token edge case still bites you? Drop it in the comments.
Parts of this post were drafted with AI assistance and reviewed by me.
