Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

JWT access + refresh tokens in ASP.NET Core (.NET 10): rotation, revocation and the bugs to avoid

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 ph

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

  1. 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).
  2. No rotation. One long-lived refresh token for weeks means a single XSS or log leak is a permanent login. Rotate on every refresh.
  3. 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."
  4. Putting refresh tokens in localStorage on a SPA and also reflecting them into logs. Prefer httpOnly Secure cookies for browser apps, or at least never log Authorization / refresh bodies.
  5. 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.
  6. Symmetric key in appsettings.json committed to git. Min 32 chars, user-secrets locally, Key Vault / env in prod, ValidateOnStart so a missing key fails at boot.
  7. Forgetting rate limits on /login and /refresh. Credential stuffing and refresh hammering are free without them. I use a fixed window per IP (and configure Forwarded Headers behind a proxy).
  8. MapInboundClaims left at default. Then sub becomes http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier and your User.FindFirst("sub") returns null. Set MapInboundClaims = false and pick claim types deliberately.

Client shape (quick)

  • Access token: Authorization: Bearer รขโ‚ฌยฆ on API calls.
  • On 401 from a protected endpoint: call /api/auth/refresh once 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.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.