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.
Top comments (0)