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
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
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;
}
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);
}
}
}
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)
});
}
}
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;
}
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
Configuration
{
"JwtSettings": {
"Secret": "your-256-bit-secret-key-min-32-chars",
"Issuer": "YourApp",
"Audience": "YourApp.Client",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
}
}
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!
};
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.
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)