Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 029 / 100

Refresh token rotation in .NET: best practices explained

Stay logged in safely without long-lived access tokens.

Expona Admin11 min read

Refresh token rotation in .NET: best practices explained

When users log into your application, you face a fundamental security trade-off: short-lived access tokens are safer but force frequent re-authentication, while long-lived tokens let users stay logged in but increase the window of vulnerability if stolen. Refresh token rotation in .NET solves this by cycling out refresh tokens on every use, detecting token theft the moment an attacker tries to reuse an old one. This article walks you through implementing a production-grade refresh token system that keeps your users logged in safely.

Why refresh token rotation matters

Traditional authentication flows issue a refresh token once and let it live for weeks or months. If that token leaks—via a compromised device, network intercept, or malicious browser extension—an attacker can silently refresh their access tokens indefinitely. You won't know about the breach until someone reports suspicious activity.

Refresh token rotation changes the game. Every time a user calls your refresh endpoint, you issue a brand-new token and invalidate the old one. If an attacker tries to use a stolen token a second time, it's already dead. You catch the theft immediately, revoke the entire token family, and can alert the user.

The cost is minimal—one extra database write per refresh—and the payoff is massive: you can now use short-lived access tokens (5–15 minutes) while keeping a reasonable user experience through long-lived refresh tokens that rotate continuously.

Store refresh tokens hashed in the database

Never store plaintext refresh tokens in your database. If someone gains database access, they gain every active session. Hash them using a strong algorithm like bcrypt or PBKDF2, the same way you'd hash passwords.

When a user logs in, generate a cryptographically random token, hash it, and store the hash. When they call refresh, compare the incoming token's hash against the stored hash. This way, even a database dump won't leak usable tokens.

csharp
public class RefreshTokenEntity
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string TokenHash { get; set; }
    public string TokenFamily { get; set; }
    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }
    public bool IsRevoked { get; set; }
    public UserEntity User { get; set; }
}

public class AuthService
{
    private readonly IPasswordHasher _hasher;
    private readonly ApplicationDbContext _db;
    private readonly ILogger _logger;

    public async Task<(string AccessToken, string RefreshToken)> LoginAsync(
        string email, 
        string password)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
        if (user == null)
            throw new UnauthorizedAccessException("Invalid credentials");

        // Verify password (using ASP.NET Core Identity's hasher)
        var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
        if (result == PasswordVerificationResult.Failed)
            throw new UnauthorizedAccessException("Invalid credentials");

        var accessToken = GenerateAccessToken(user.Id);
        var (refreshToken, refreshTokenHash) = GenerateRefreshToken();
        var tokenFamily = Guid.NewGuid().ToString();

        // Store hashed token with a family ID for breach detection
        var refreshTokenEntity = new RefreshTokenEntity
        {
            UserId = user.Id,
            TokenHash = refreshTokenHash,
            TokenFamily = tokenFamily,
            ExpiresAt = DateTime.UtcNow.AddDays(7),
            CreatedAt = DateTime.UtcNow,
            IsRevoked = false
        };

        _db.RefreshTokens.Add(refreshTokenEntity);
        await _db.SaveChangesAsync();

        return (accessToken, refreshToken);
    }

    private (string Token, string Hash) GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
        {
            rng.GetBytes(randomBytes);
        }
        var token = Convert.ToBase64String(randomBytes);
        var hash = BC.HashPassword(token);
        return (token, hash);
    }
}

Store the refresh token in a table with userId, a hash, an expiration date, and a family identifier. That family ID is the key to detecting theft—more on that next.

Issue a new refresh token on every use

When a user calls your refresh endpoint, don't just validate the old token and issue a new access token. Issue a new refresh token too, and mark the old one as used. This forces tokens to be single-use.

The flow is simple: user sends refresh token → validate it hasn't expired and hasn't been revoked → hash and compare → issue fresh access and refresh tokens → save the new tokens → mark the old refresh token as retired.

csharp
public async Task<(string AccessToken, string RefreshToken)> RefreshAsync(
    string refreshToken,
    int userId)
{
    if (string.IsNullOrEmpty(refreshToken))
        throw new UnauthorizedAccessException("Refresh token required");

    // Hash the incoming token to find it in the database
    var incomingTokenHash = BC.HashPassword(refreshToken);
    var storedToken = await _db.RefreshTokens
        .FirstOrDefaultAsync(rt => rt.UserId == userId && !rt.IsRevoked);

    if (storedToken == null)
        throw new UnauthorizedAccessException("Invalid refresh token");

    // Compare hashes (bcrypt does this safely)
    if (!BC.Verify(refreshToken, storedToken.TokenHash))
        throw new UnauthorizedAccessException("Token hash mismatch");

    if (storedToken.ExpiresAt < DateTime.UtcNow)
        throw new UnauthorizedAccessException("Refresh token expired");

    var user = await _db.Users.FindAsync(userId);
    if (user == null)
        throw new UnauthorizedAccessException("User not found");

    // Generate new tokens
    var newAccessToken = GenerateAccessToken(userId);
    var (newRefreshToken, newRefreshTokenHash) = GenerateRefreshToken();

    // Create new refresh token entry with the same family
    var newTokenEntity = new RefreshTokenEntity
    {
        UserId = userId,
        TokenHash = newRefreshTokenHash,
        TokenFamily = storedToken.TokenFamily, // Preserve family for breach detection
        ExpiresAt = DateTime.UtcNow.AddDays(7),
        CreatedAt = DateTime.UtcNow,
        IsRevoked = false
    };

    _db.RefreshTokens.Add(newTokenEntity);

    // Mark the old token as used (optional; you could also delete it)
    storedToken.IsRevoked = true;

    await _db.SaveChangesAsync();

    _logger.LogInformation(
        "Refresh token rotated for user {UserId}, family {Family}",
        userId,
        storedToken.TokenFamily);

    return (newAccessToken, newRefreshToken);
}

By issuing a new refresh token every time, you ensure that each token is consumed exactly once. The next time the client calls refresh, they'll use the new token, not the old one. An attacker holding the old token will hit a revoked-token error.

Reuse of an old token means theft — revoke the family

Here's the core insight: if a refresh token is used twice, someone is using a stale copy. That's a breach. The correct response is to assume the token was stolen and revoke not just that token, but the entire family—every token issued to that user in this authentication session.

When you detect token reuse, revoke all tokens with the same family ID, force the user to log in again, and optionally alert them. This stops an attacker from using old tokens they may have harvested.

csharp
public async Task<(string AccessToken, string RefreshToken)> RefreshAsync(
    string refreshToken,
    int userId)
{
    if (string.IsNullOrEmpty(refreshToken))
        throw new UnauthorizedAccessException("Refresh token required");

    var storedToken = await _db.RefreshTokens
        .FirstOrDefaultAsync(rt => rt.UserId == userId && !rt.IsRevoked);

    if (storedToken == null)
        throw new UnauthorizedAccessException("Invalid refresh token");

    if (!BC.Verify(refreshToken, storedToken.TokenHash))
        throw new UnauthorizedAccessException("Token hash mismatch");

    if (storedToken.ExpiresAt < DateTime.UtcNow)
        throw new UnauthorizedAccessException("Refresh token expired");

    // Check if this token has already been used
    if (storedToken.IsRevoked)
    {
        // Token reuse detected! Breach imminent. Revoke the entire family.
        var familyTokens = await _db.RefreshTokens
            .Where(rt => rt.TokenFamily == storedToken.TokenFamily 
                && rt.UserId == userId)
            .ToListAsync();

        foreach (var token in familyTokens)
        {
            token.IsRevoked = true;
        }

        await _db.SaveChangesAsync();

        _logger.LogWarning(
            "Token reuse detected for user {UserId}, family {Family}. "
            + "Entire family revoked.",
            userId,
            storedToken.TokenFamily);

        // Optionally: send a security alert email
        await _notificationService.SendSecurityAlertAsync(
            userId,
            "Unusual login activity detected. Your sessions have been terminated.");

        throw new UnauthorizedAccessException(
            "Token reuse detected. Please log in again.");
    }

    // Normal flow: issue new tokens
    var newAccessToken = GenerateAccessToken(userId);
    var (newRefreshToken, newRefreshTokenHash) = GenerateRefreshToken();

    var newTokenEntity = new RefreshTokenEntity
    {
        UserId = userId,
        TokenHash = newRefreshTokenHash,
        TokenFamily = storedToken.TokenFamily,
        ExpiresAt = DateTime.UtcNow.AddDays(7),
        CreatedAt = DateTime.UtcNow,
        IsRevoked = false
    };

    _db.RefreshTokens.Add(newTokenEntity);
    storedToken.IsRevoked = true;
    await _db.SaveChangesAsync();

    return (newAccessToken, newRefreshToken);
}

This approach, called token family revocation, is used by OAuth 2.0 providers like Google and Microsoft. It's battle-tested and gives you an early warning system for account compromise.

Where you store the refresh token matters as much as how you rotate it. The safest place is an httpOnly, Secure, SameSite cookie that your frontend cannot access via JavaScript.

  • httpOnly: JavaScript can't read it, blocking XSS attacks from exfiltrating the token.
  • Secure: Only sent over HTTPS, preventing man-in-the-middle interception.
  • SameSite: Only sent on same-site requests, blocking CSRF attacks from other domains.

When you issue tokens after login or refresh, set the cookie. When the browser calls your refresh endpoint, the cookie is automatically included. Your API validates the token without JavaScript ever touching it.

csharp
// In your login/refresh controller
[HttpPost("login")]
public async Task LoginAsync([FromBody] LoginRequest request)
{
    try
    {
        var (accessToken, refreshToken) = await _authService.LoginAsync(
            request.Email,
            request.Password);

        SetRefreshTokenCookie(refreshToken);

        return Ok(new { accessToken });
    }
    catch (UnauthorizedAccessException ex)
    {
        return Unauthorized(new { error = ex.Message });
    }
}

[HttpPost("refresh")]
public async Task RefreshAsync()
{
    try
    {
        // Extract user ID from current access token or session
        var userId = int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)?.Value 
            ?? throw new UnauthorizedAccessException());

        // The refresh token comes from the httpOnly cookie automatically
        var refreshTokenFromCookie = Request.Cookies["refreshToken"];

        if (string.IsNullOrEmpty(refreshTokenFromCookie))
            return Unauthorized(new { error = "Refresh token missing" });

        var (newAccessToken, newRefreshToken) = await _authService.RefreshAsync(
            refreshTokenFromCookie,
            userId);

        SetRefreshTokenCookie(newRefreshToken);

        return Ok(new { accessToken = newAccessToken });
    }
    catch (UnauthorizedAccessException ex)
    {
        ClearRefreshTokenCookie();
        return Unauthorized(new { error = ex.Message });
    }
}

private void SetRefreshTokenCookie(string refreshToken)
{
    var cookieOptions = new CookieOptions
    {
        HttpOnly = true,
        Secure = true, // HTTPS only
        SameSite = SameSiteMode.Strict, // or Lax for some cross-site scenarios
        Expires = DateTimeOffset.UtcNow.AddDays(7),
        IsEssential = true
    };

    Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
}

private void ClearRefreshTokenCookie()
{
    Response.Cookies.Delete("refreshToken");
}

Your frontend doesn't need to store the refresh token anywhere. The browser handles it automatically. Your JavaScript just needs to store the short-lived access token (in memory or sessionStorage if needed) and call the refresh endpoint when it expires. No token management library required.

Common mistakes

Storing plaintext refresh tokens

We've said it, but it bears repeating: hash every refresh token the same way you hash passwords. A plaintext token in the database is as bad as a plaintext password.

Not rotating on every refresh

Some teams issue a new access token on refresh but reuse the same refresh token. This defeats the entire point of rotation. If an attacker steals the refresh token, they can use it forever. Always issue a new one.

Ignoring token reuse

If you don't check whether a token was already used, you can't detect breaches. Token reuse is the signal that something is wrong. Treat it as a security event.

Sending refresh tokens in the response body

If your API returns the refresh token in a JSON response, JavaScript can read it. An XSS attack can steal it. Use httpOnly cookies or, if you must use the body, ensure your frontend never logs it or transmits it to analytics services.

Missing SameSite attribute

Without SameSite=Strict or Lax, a CSRF attack from another domain can trick a user's browser into calling your refresh endpoint. Always set it.

Key takeaways

  • Hash refresh tokens in the database using bcrypt or PBKDF2, just like passwords.
  • Issue a new refresh token on every refresh call, making each token single-use.
  • Detect token theft by checking for token reuse; if detected, revoke the entire family and force re-authentication.
  • Store refresh tokens in httpOnly, Secure, SameSite cookies to prevent JavaScript and CSRF attacks.
  • Use short-lived access tokens (5–15 minutes) and long-lived, rotating refresh tokens (7 days) to balance security and UX.
  • Log token rotation and breach events for audit trails and security monitoring.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles