DEV Community

CsharpDeveloper
CsharpDeveloper

Posted on

JWT Refresh Token Rotation in .NET — Why Your Auth is Probably Broken

Most JWT implementations I see in .NET projects have the same problem: the refresh token never changes. Once issued, it sits in the database (or worse, in a cookie) forever until it expires.

This is a security hole. Here's why, and how to fix it with refresh token rotation.

The Problem

Standard JWT flow:

1. User logs in → gets access token (15 min) + refresh token (7 days)
2. Access token expires
3. Client sends refresh token → gets new access token
4. Same refresh token is reused for 7 days
Enter fullscreen mode Exit fullscreen mode

What happens if someone steals the refresh token on day 1? They have 7 full days of access to the account. The real user has no idea.

The Fix: Token Rotation

With rotation, every time a refresh token is used, it's revoked and replaced with a new one:

1. User logs in → access token + refresh token A
2. Access token expires
3. Client sends refresh token A → gets new access token + refresh token B
4. Refresh token A is now dead
5. If anyone tries to use refresh token A again → ALERT, revoke everything
Enter fullscreen mode Exit fullscreen mode

Step 5 is the key. If a stolen token is used after the legitimate user already rotated it, the system knows something is wrong.

Implementation in .NET

The Entity

public class RefreshToken
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Token { get; set; }
    public DateTime ExpiresAt { get; set; }
    public bool IsRevoked { get; set; }
    public DateTime? RevokedAt { get; set; }
    public string? ReplacedByToken { get; set; }
    public Guid UserId { get; set; }
    public ApplicationUser User { get; set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsActive => !IsRevoked && !IsExpired;
}
Enter fullscreen mode Exit fullscreen mode

Notice the ReplacedByToken field — this creates a chain. When a token is rotated, we record which token replaced it. This lets us trace the entire token lineage if something goes wrong.

The Token Service

public class TokenService : ITokenService
{
    private readonly IConfiguration _config;
    private readonly ApplicationDbContext _context;

    public string GenerateAccessToken(ApplicationUser user,
        IList<string> roles, Guid? tenantId)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Email, user.Email!),
            new("tenant_id", tenantId?.ToString() ?? "")
        };

        foreach (var role in roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["JwtSettings:Secret"]!));

        var token = new JwtSecurityToken(
            issuer: _config["JwtSettings:Issuer"],
            audience: _config["JwtSettings:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(15),
            signingCredentials: new SigningCredentials(
                key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public async Task<RefreshToken> GenerateRefreshTokenAsync(
        Guid userId, CancellationToken ct = default)
    {
        var refreshToken = new RefreshToken
        {
            Token = Convert.ToBase64String(
                RandomNumberGenerator.GetBytes(64)),
            ExpiresAt = DateTime.UtcNow.AddDays(7),
            UserId = userId
        };

        _context.RefreshTokens.Add(refreshToken);
        await _context.SaveChangesAsync(ct);
        return refreshToken;
    }

    public async Task RevokeRefreshTokenAsync(
        string token, string? replacedByToken = null,
        CancellationToken ct = default)
    {
        var refreshToken = await _context.RefreshTokens
            .FirstOrDefaultAsync(t => t.Token == token, ct);

        if (refreshToken != null)
        {
            refreshToken.IsRevoked = true;
            refreshToken.RevokedAt = DateTime.UtcNow;
            refreshToken.ReplacedByToken = replacedByToken;
            await _context.SaveChangesAsync(ct);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Rotation Flow

public class RefreshTokenCommandHandler
    : IRequestHandler<RefreshTokenCommand, Result<TokenResponse>>
{
    private readonly ITokenService _tokenService;
    private readonly UserManager<ApplicationUser> _userManager;

    public async Task<Result<TokenResponse>> Handle(
        RefreshTokenCommand request, CancellationToken ct)
    {
        // 1. Validate the incoming refresh token
        var existingToken = await _tokenService
            .ValidateRefreshTokenAsync(request.Token, ct);

        if (existingToken == null)
            return Result<TokenResponse>
                .Failure("Invalid or expired refresh token.");

        // 2. Find the user
        var user = await _userManager
            .FindByIdAsync(existingToken.UserId.ToString());

        if (user == null || !user.IsActive)
            return Result<TokenResponse>
                .Failure("User not found or deactivated.");

        // 3. Generate NEW refresh token
        var newRefreshToken = await _tokenService
            .GenerateRefreshTokenAsync(user.Id, ct);

        // 4. Revoke the OLD token and link to new one
        await _tokenService.RevokeRefreshTokenAsync(
            request.Token, newRefreshToken.Token, ct);

        // 5. Generate new access token
        var roles = await _userManager.GetRolesAsync(user);
        var accessToken = _tokenService
            .GenerateAccessToken(user, roles, user.TenantId);

        return Result<TokenResponse>.Success(new TokenResponse
        {
            AccessToken = accessToken,
            RefreshToken = newRefreshToken.Token,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15)
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The critical part is step 4 — the old token is revoked and linked to the new one via ReplacedByToken.

Detecting Token Theft

If someone tries to use a revoked token, you know it's been stolen. You can then revoke the entire token family:

public async Task<RefreshToken?> ValidateRefreshTokenAsync(
    string token, CancellationToken ct = default)
{
    var refreshToken = await _context.RefreshTokens
        .FirstOrDefaultAsync(
            t => t.Token == token &&
            !t.IsRevoked &&
            t.ExpiresAt > DateTime.UtcNow, ct);

    return refreshToken;
}
Enter fullscreen mode Exit fullscreen mode

If the token is revoked but someone tries to use it → the legitimate token was already rotated → someone has the old token → compromise detected.

The Complete Auth Flow

POST /api/auth/register
  → Creates user, tenant, free trial subscription
  → Returns access token + refresh token

POST /api/auth/login
  → Validates credentials
  → Returns access token + refresh token

POST /api/auth/refresh
  → Validates refresh token
  → Revokes old token
  → Issues new access token + new refresh token
  → Links old → new token

POST /api/auth/forgot-password
  → Generates reset token
  → Sends email (doesn't reveal if user exists)

POST /api/auth/reset-password
  → Validates reset token
  → Updates password

GET /api/auth/me
  → Returns current user from JWT claims
Enter fullscreen mode Exit fullscreen mode

Configuration

{
  "JwtSettings": {
    "Secret": "your-256-bit-secret-key-min-32-chars",
    "Issuer": "YourApp",
    "Audience": "YourApp.Client",
    "AccessTokenExpirationMinutes": 15,
    "RefreshTokenExpirationDays": 7
  }
}
Enter fullscreen mode Exit fullscreen mode

Access token: 15 minutes — short enough that a stolen token has limited damage.

Refresh token: 7 days — long enough for good UX, but rotated on every use.

Common Mistakes

1. Storing refresh tokens in localStorage — Use httpOnly cookies instead. localStorage is accessible to any JavaScript on the page (XSS vulnerability).

2. Not setting ClockSkew to zero:

options.TokenValidationParameters = new TokenValidationParameters
{
    ClockSkew = TimeSpan.Zero // Default is 5 minutes!
};
Enter fullscreen mode Exit fullscreen mode

Without this, expired tokens are valid for an extra 5 minutes.

3. Using the same secret in all environments — Dev, staging, and production should all have different JWT secrets.

4. Not revoking all tokens on password change — When a user changes their password, invalidate all existing refresh tokens.

Try It Out

I built a complete SaaS boilerplate that includes this auth system, plus multi-tenancy, Stripe billing, Clean Architecture with CQRS, and more.

SaaS Kit on Gumroad →

The auth system is production-ready with proper token rotation, role-based access, and password reset flow — all wired up and tested.


How do you handle refresh tokens in your .NET projects? Let me know in the comments.

Top comments (0)