Refresh Tokens — Sliding Expiry and Token Rotation

📋 Table of Contents
  1. Refresh Token Endpoint
  2. Common Mistakes

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),
    });
}
Note: Token reuse detection works on the token family concept. When refresh token R1 is used to get R2, R1 is marked as used/replaced. If an attacker uses R1 again (or if the legitimate user’s copy of R1 is somehow replayed), the server detects that R1 was already consumed and revokes the entire chain (R2 and any tokens derived from it). This forces both the attacker and the legitimate user to re-authenticate. It is a strong signal of token theft.
Tip: For the Angular side, implement an HTTP interceptor that catches 401 responses, calls 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).
Warning: Refresh tokens stored in browser 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.

🧠 Test Yourself

User A has refresh token R1. An attacker steals R1 and uses it to get R2 before user A does. User A later tries to use R1. What should happen?