.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
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; }
}// 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.
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
[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
[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:
- The original token was stolen, OR
- A legitimate response was lost and the client is replaying
Either way, revoke the entire family — log out all sessions in that chain:
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
[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
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:
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);
}
}
}