.NET & C# Development · Lesson 34 of 92

Add Refresh Tokens — Sessions That Survive Expiry

Why Short-Lived JWTs Need Refresh Tokens

A JWT is a bearer token — whoever holds it can use it. If it's stolen (XSS, network interception), the attacker has access until it expires. Shorter expiry reduces the window.

But a 15-minute expiry means users must re-authenticate every 15 minutes. Not acceptable.

The solution: pair a short-lived access token (15 min) with a long-lived refresh token (7–30 days). The refresh token is stored server-side and can be revoked. When the access token expires, the client silently exchanges the refresh token for a new pair.

Refresh Token Data Model

C#
public class RefreshToken
{
    public int Id { get; set; }
    public string TokenHash { get; set; } = string.Empty; // hashed — never store raw tokens
    public int UserId { get; set; }
    public User User { get; set; } = null!;
    public DateTime ExpiresAt { get; set; }
    public bool IsRevoked { get; set; }
    public DateTime CreatedAt { get; set; }
    public string? RevokedReason { get; set; }

    // Token family — for theft detection
    public string FamilyId { get; set; } = string.Empty;

    // The token this one replaced (audit trail)
    public string? ReplacedByTokenHash { get; set; }
}
C#
// EF configuration
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
    public void Configure(EntityTypeBuilder<RefreshToken> builder)
    {
        builder.HasIndex(t => t.TokenHash).IsUnique();
        builder.HasIndex(t => t.FamilyId);
        builder.HasIndex(t => new { t.UserId, t.IsRevoked });

        builder.Property(t => t.TokenHash).HasMaxLength(256);
        builder.Property(t => t.FamilyId).HasMaxLength(36);
    }
}

Storing Hashed Refresh Tokens

Never store raw tokens. If your DB leaks, attackers shouldn't be able to use the tokens directly.

C#
public class RefreshTokenService
{
    private readonly AppDbContext _db;

    public RefreshTokenService(AppDbContext db) => _db = db;

    public (string rawToken, string hash) GenerateToken()
    {
        var raw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
        var hash = HashToken(raw);
        return (raw, hash);
    }

    public string HashToken(string rawToken)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
        return Convert.ToBase64String(bytes);
    }

    public async Task<RefreshToken> CreateAsync(int userId, string familyId)
    {
        var (rawToken, hash) = GenerateToken();

        var token = new RefreshToken
        {
            TokenHash = hash,
            UserId    = userId,
            FamilyId  = familyId,
            ExpiresAt = DateTime.UtcNow.AddDays(30),
            CreatedAt = DateTime.UtcNow
        };

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

        return token with { TokenHash = rawToken }; // return raw token to caller only
    }
}

The raw token is returned once — to be set as an HttpOnly cookie or returned in the response. After that, only the hash is stored.

Login Endpoint — Issue Token Pair

C#
[HttpPost("auth/login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
    var user = await _userService.ValidateCredentialsAsync(request.Email, request.Password);
    if (user is null) return Unauthorized();

    var familyId   = Guid.NewGuid().ToString();
    var accessToken  = _jwtService.GenerateAccessToken(user);
    var rawRefreshToken = await _refreshTokenService.CreateRawAsync(user.Id, familyId);

    // Set refresh token as HttpOnly cookie — not accessible to JavaScript
    Response.Cookies.Append("refreshToken", rawRefreshToken, new CookieOptions
    {
        HttpOnly = true,
        Secure   = true,
        SameSite = SameSiteMode.Strict,
        Expires  = DateTimeOffset.UtcNow.AddDays(30)
    });

    return Ok(new { accessToken });
}

/auth/refresh Endpoint — Validate and Rotate

C#
[HttpPost("auth/refresh")]
public async Task<IActionResult> Refresh()
{
    var rawToken = Request.Cookies["refreshToken"];
    if (string.IsNullOrEmpty(rawToken)) return Unauthorized();

    var hash  = _refreshTokenService.HashToken(rawToken);
    var token = await _db.RefreshTokens
        .Include(t => t.User)
        .FirstOrDefaultAsync(t => t.TokenHash == hash);

    if (token is null)
        return Unauthorized(new { error = "Invalid token." });

    // Detect theft: token was already used (reuse of a consumed token)
    if (token.IsRevoked)
    {
        await RevokeEntireFamilyAsync(token.FamilyId, "Reuse detected — possible theft.");
        return Unauthorized(new { error = "Token reuse detected. All sessions revoked." });
    }

    if (token.ExpiresAt < DateTime.UtcNow)
        return Unauthorized(new { error = "Refresh token expired." });

    // Rotate: revoke old token, issue new token in the same family
    token.IsRevoked      = true;
    token.RevokedReason  = "Rotated";

    var newAccessToken   = _jwtService.GenerateAccessToken(token.User);
    var newRawRefreshToken = await _refreshTokenService.CreateRawAsync(
        token.User.Id, token.FamilyId);

    token.ReplacedByTokenHash = _refreshTokenService.HashToken(newRawRefreshToken);
    await _db.SaveChangesAsync();

    Response.Cookies.Append("refreshToken", newRawRefreshToken, new CookieOptions
    {
        HttpOnly = true,
        Secure   = true,
        SameSite = SameSiteMode.Strict,
        Expires  = DateTimeOffset.UtcNow.AddDays(30)
    });

    return Ok(new { accessToken = newAccessToken });
}

Token Rotation and Family Tracking for Theft Detection

The refresh token is single-use. Each use issues a replacement. This is token rotation.

The family ID links all tokens issued for the same login session. If a token that was already rotated (i.e., already used and replaced) is presented again, it means:

  1. The original token was stolen, OR
  2. A legitimate response was lost and the client is replaying

Either way, revoke the entire family — log out all sessions in that chain:

C#
private async Task RevokeEntireFamilyAsync(string familyId, string reason)
{
    var familyTokens = await _db.RefreshTokens
        .Where(t => t.FamilyId == familyId && !t.IsRevoked)
        .ToListAsync();

    foreach (var t in familyTokens)
    {
        t.IsRevoked     = true;
        t.RevokedReason = reason;
    }

    await _db.SaveChangesAsync();

    _logger.LogWarning(
        "Revoked {Count} tokens in family {FamilyId}. Reason: {Reason}",
        familyTokens.Count, familyId, reason);
}

Logout — Revoke All Tokens

C#
[HttpPost("auth/logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
    var rawToken = Request.Cookies["refreshToken"];

    if (!string.IsNullOrEmpty(rawToken))
    {
        var hash  = _refreshTokenService.HashToken(rawToken);
        var token = await _db.RefreshTokens.FirstOrDefaultAsync(t => t.TokenHash == hash);

        if (token is not null)
        {
            // Revoke all tokens for this user (logout everywhere)
            var allUserTokens = await _db.RefreshTokens
                .Where(t => t.UserId == token.UserId && !t.IsRevoked)
                .ToListAsync();

            foreach (var t in allUserTokens)
            {
                t.IsRevoked     = true;
                t.RevokedReason = "User logged out";
            }

            await _db.SaveChangesAsync();
        }
    }

    Response.Cookies.Delete("refreshToken");
    return NoContent();
}

JWT Service — Short-Lived Access Tokens

C#
public class JwtService
{
    private readonly JwtSettings _settings;

    public JwtService(IOptions<JwtSettings> options) => _settings = options.Value;

    public string GenerateAccessToken(User user)
    {
        var key    = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
        var creds  = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(ClaimTypes.Name,               user.Username),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString())
        };

        var token = new JwtSecurityToken(
            issuer:   _settings.Issuer,
            audience: _settings.Audience,
            claims:   claims,
            expires:  DateTime.UtcNow.AddMinutes(15), // short-lived
            signingCredentials: creds);

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

Housekeeping — Clean Up Expired Tokens

Old tokens accumulate. Run a background job to purge them:

C#
public class TokenCleanupService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public TokenCleanupService(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromHours(6), stoppingToken);

            await using var scope = _scopeFactory.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            var cutoff = DateTime.UtcNow.AddDays(-7); // keep 7 days of history
            await db.RefreshTokens
                .Where(t => t.IsRevoked && t.CreatedAt < cutoff)
                .ExecuteDeleteAsync(stoppingToken);
        }
    }
}