Refresh Tokens — Keeping Users Logged In Safely
Implement refresh token rotation in ASP.NET Core: storing refresh tokens securely, the rotation pattern, detecting token reuse attacks, and why refresh tokens must be treated like passwords.
Why Refresh Tokens Exist
Access tokens should be short-lived (15 minutes) to limit the damage if stolen. But short-lived tokens mean users have to log in every 15 minutes. Refresh tokens solve this: a long-lived token (7–30 days) that can be exchanged for a new access token, without re-entering credentials.
Flow:
Login → access token (15 min) + refresh token (7 days)
Access token expires → POST /auth/refresh with refresh token
Server validates refresh token → issues new access token + new refresh token
Old refresh token is invalidated (rotation)The refresh token is stored server-side and validated on every use.
Database Schema
// Domain/Auth/RefreshToken.cs
public sealed class RefreshToken
{
public Guid Id { get; private set; }
public Guid UserId { get; private set; }
public string Token { get; private set; } = null!;
public string TokenHash { get; private set; } = null!; // store hash, not plaintext
public DateTime ExpiresAt { get; private set; }
public DateTime CreatedAt { get; private set; }
public string CreatedByIp{ get; private set; } = null!;
public bool IsRevoked { get; private set; }
public Guid? ReplacedBy { get; private set; } // rotation chain
public static RefreshToken Create(Guid userId, string ipAddress)
{
var rawToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
return new RefreshToken
{
Id = Guid.NewGuid(),
UserId = userId,
Token = rawToken,
TokenHash = HashToken(rawToken),
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow,
CreatedByIp = ipAddress,
IsRevoked = false
};
}
public static string HashToken(string token) =>
Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
public bool IsActive => !IsRevoked && ExpiresAt > DateTime.UtcNow;
}Token Generation on Login
// Application/Auth/LoginHandler.cs
public async Task<Result<LoginResponse>> Handle(LoginCommand cmd, CancellationToken ct)
{
var user = await _users.GetByEmailAsync(cmd.Email, ct);
if (user is null || !_hasher.Verify(cmd.Password, user.PasswordHash))
return Result.Failure<LoginResponse>(AuthErrors.InvalidCredentials);
var accessToken = _tokenService.GenerateAccessToken(user);
var refreshToken = RefreshToken.Create(user.Id, cmd.IpAddress);
await _tokens.AddAsync(refreshToken, ct);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success(new LoginResponse(
AccessToken: accessToken,
RefreshToken: refreshToken.Token, // send raw token to client
AccessTokenExpiry: DateTime.UtcNow.AddMinutes(15),
RefreshTokenExpiry: refreshToken.ExpiresAt));
}Refresh Endpoint with Rotation
// Application/Auth/RefreshTokenHandler.cs
public async Task<Result<LoginResponse>> Handle(
RefreshTokenCommand cmd, CancellationToken ct)
{
var hash = RefreshToken.HashToken(cmd.Token);
var stored = await _tokens.GetByHashAsync(hash, ct);
if (stored is null || !stored.IsActive)
{
// Token not found or revoked — possible reuse attack
if (stored?.UserId is Guid compromisedUser)
await RevokeAllUserTokensAsync(compromisedUser, "reuse-detected", ct);
return Result.Failure<LoginResponse>(AuthErrors.InvalidRefreshToken);
}
var user = await _users.GetByIdAsync(stored.UserId, ct);
var newAccess = _tokenService.GenerateAccessToken(user!);
var newRefresh = RefreshToken.Create(user!.Id, cmd.IpAddress);
// Rotation: revoke old, issue new
stored.Revoke(replacedById: newRefresh.Id);
await _tokens.AddAsync(newRefresh, ct);
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success(new LoginResponse(
AccessToken: newAccess,
RefreshToken: newRefresh.Token,
AccessTokenExpiry: DateTime.UtcNow.AddMinutes(15),
RefreshTokenExpiry: newRefresh.ExpiresAt));
}
private async Task RevokeAllUserTokensAsync(Guid userId, string reason, CancellationToken ct)
{
var tokens = await _tokens.GetActiveByUserIdAsync(userId, ct);
foreach (var t in tokens)
t.Revoke(reason: reason);
await _unitOfWork.SaveChangesAsync(ct);
}The Reuse Attack Detection
Scenario: attacker steals a refresh token
Normal flow:
Client uses refresh token → rotated (old invalidated, new issued)
Attack detected:
Attacker uses the OLD (now invalidated) refresh token
Server sees: this token exists but is revoked
Server concludes: token reuse → both client and attacker have been using this chain
Server response: revoke ALL tokens for this user, force re-login
Result: attacker gets nothing; legitimate user gets logged out and warnedProduction issue I've seen: A hospital's API had no reuse detection. An attacker who stole a refresh token from a network capture could keep refreshing indefinitely — each refresh gave them a new valid token. The original token appeared "used" but there was no check for whether it had already been rotated. Full rotation + reuse detection closed this gap.
Secure Storage on the Client
Browser:
DO NOT store in localStorage — XSS can read it
Store access token: memory (JavaScript variable)
Store refresh token: HttpOnly, Secure, SameSite=Strict cookie
→ JavaScript cannot read it → XSS cannot steal it
Mobile (iOS/Android):
Use Secure Enclave / Keychain / Keystore
Never store tokens in shared preferences or UserDefaults unencrypted
Backend-for-Frontend (BFF):
The BFF holds the refresh token — browser never sees it
BFF exchanges refresh token on behalf of client
Most secure architecture for browser-based appsSetting the Cookie
// In the login/refresh endpoint response
HttpContext.Response.Cookies.Append("refresh_token", refreshToken.Token,
new CookieOptions
{
HttpOnly = true, // JS cannot access
Secure = true, // HTTPS only
SameSite = SameSiteMode.Strict,
Expires = refreshToken.ExpiresAt,
Path = "/auth/refresh" // cookie only sent to the refresh endpoint
});Logout
app.MapPost("/auth/logout", async (
HttpContext ctx,
RefreshTokenRepository tokens,
IUnitOfWork uow) =>
{
var raw = ctx.Request.Cookies["refresh_token"];
if (raw is not null)
{
var hash = RefreshToken.HashToken(raw);
var stored = await tokens.GetByHashAsync(hash);
stored?.Revoke("logout");
await uow.SaveChangesAsync();
}
ctx.Response.Cookies.Delete("refresh_token");
return Results.NoContent();
}).RequireAuthorization();Red Flag / Green Answer
Red Flag: "We store the refresh token in localStorage so the JavaScript can send it with each request."
localStorage is readable by any JavaScript on the page. An XSS vulnerability — in your code, a CDN, or any npm package you use — can exfiltrate the token silently. Once a refresh token is stolen, the attacker has multi-day access.
Green Answer:
Refresh token in an HttpOnly Secure SameSite cookie. The browser sends it automatically on refresh requests. JavaScript never reads it. XSS cannot exfiltrate what it cannot read.
Key Takeaway
Refresh tokens must be treated as credentials: hashed at rest, rotated on every use, and revoked immediately on suspected reuse. The rotation + reuse detection pattern means a stolen token triggers lockout of the entire session chain. Short-lived access tokens + rotated refresh tokens is the production-safe authentication pattern.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.