Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
JWTRefresh TokensASP.NET CoreSecurity.NET
Share:𝕏

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

C#
// 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

C#
// 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

C#
// 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 warned

Production 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 apps

Setting the Cookie

C#
// 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

C#
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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.