Refresh token rotation is the mechanism that makes long-lived refresh tokens safe. On each use, the old refresh token is invalidated and a new one is issued. If an attacker steals a refresh token and uses it, the legitimate user’s next refresh attempt fails (the old token was already consumed by the attacker) — the server detects the reuse and can revoke all tokens in the family. The Angular HTTP interceptor automates the refresh process: it intercepts 401 responses, calls the refresh endpoint, updates the stored tokens, and retries the original request transparently.
Refresh Token Endpoint
// ── RefreshToken entity ───────────────────────────────────────────────────
public class RefreshToken
{
public int Id { get; set; }
public string Token { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UsedAt { get; set; } // null = not yet used
public DateTime? RevokedAt { get; set; } // null = not revoked
public string? ReplacedBy { get; set; } // new token after rotation
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt is not null;
public bool IsActive => !IsExpired && !IsRevoked;
}
// ── POST /api/auth/refresh ────────────────────────────────────────────────
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Refresh(
RefreshRequest request, CancellationToken ct)
{
var storedToken = await _refreshTokenRepo
.GetByTokenAsync(request.RefreshToken, ct);
if (storedToken is null || !storedToken.IsActive)
{
// Token reuse detection — if token was already used, someone is replaying it
if (storedToken?.UsedAt is not null)
{
// Revoke all tokens for this user (potential theft)
await _refreshTokenRepo.RevokeAllForUserAsync(storedToken.UserId, ct);
_logger.LogWarning("Refresh token reuse detected for user {UserId}", storedToken.UserId);
}
return Unauthorized(new { message = "Invalid or expired refresh token." });
}
// Mark old token as used
storedToken.UsedAt = DateTime.UtcNow;
storedToken.RevokedAt = DateTime.UtcNow;
// Issue new access token
var user = await _userManager.FindByIdAsync(storedToken.UserId);
var roles = await _userManager.GetRolesAsync(user!);
var newAccess = _tokenService.GenerateAccessToken(user!, roles);
// Issue new refresh token (rotation)
var newRefresh = _tokenService.GenerateRefreshToken(user!.Id);
storedToken.ReplacedBy = newRefresh.Token;
newRefresh.CreatedAt = DateTime.UtcNow;
await _refreshTokenRepo.UpdateAsync(storedToken, ct);
await _refreshTokenRepo.AddAsync(newRefresh, ct);
return Ok(new AuthResponse
{
AccessToken = newAccess,
RefreshToken = newRefresh.Token,
ExpiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes),
});
}
POST /api/auth/refresh with the stored refresh token, updates the stored access token with the new one, and retries the original failed request. Use RxJS’s catchError and switchMap. Add a flag to prevent multiple simultaneous refresh calls when several requests 401 at the same time — queue them and resolve all with the single refresh response. This pattern is covered in Chapter 52 (Angular HttpClient).localStorage are accessible to JavaScript and vulnerable to XSS. If your site has even a small XSS vulnerability, an attacker can read all stored tokens. Consider storing refresh tokens in HttpOnly, Secure, SameSite=Strict cookies — they are sent automatically with requests to the same origin but cannot be accessed by JavaScript. The trade-off: HttpOnly cookies require a same-origin or properly configured CORS API; localStorage is simpler to implement but less secure.Common Mistakes
Mistake 1 — Not implementing token rotation (unlimited reuse of stolen refresh tokens)
❌ Wrong — same refresh token reused indefinitely; stolen token grants permanent access until expiry.
✅ Correct — rotate on every use; detect reuse and revoke the entire token family.
Mistake 2 — Storing refresh tokens in plaintext (database breach exposes all tokens)
❌ Wrong — refresh token stored as-is in database; attacker who reads the DB can impersonate all users.
✅ Correct — store a SHA-256 hash of the refresh token; compare hash on validation without storing the raw token.