RO EN

Refresh Tokens in .NET

Refresh Tokens in .NET
Doru Bulubasa
04 March 2026

Correct Implementation (not copy-paste)

Series: Security by Design in .NET: From JWT to Certificate-based Authentication

In the previous article, I explained how JSON Web Tokens (JWT) work and how ASP.NET Core validates the signature.

The problem is that JWTs are stateless.

Once a token is issued:

  • the server cannot easily revoke it

  • the token remains valid until expiration

  • if it is stolen → the attacker can use it

That is why modern systems use Refresh Tokens.


🔐 What is a Refresh Token

A Refresh Token is a long-term token used to obtain a new Access Token without the user having to log in again.

Typical flow:

1️⃣ User authenticates

2️⃣ Server issues:

  • Access Token (e.g., 10 minutes)

  • Refresh Token (e.g., 7 days)

3️⃣ When the Access Token expires:

The client sends:

<code>POST /auth/refresh</code>

with the Refresh Token.

The server verifies the token and issues a new Access Token.


🔁 Token Rotation (modern practice)

A refresh token must not be reused.

Each time it is used:

1️⃣ The server invalidates the old token

2️⃣ Creates a new refresh token

3️⃣ Returns new Access Token + Refresh Token

Flow:

RT1 -> used -> invalidated
           ↓
          RT2 -> used -> invalidated
                 ↓
                RT3

Major benefit: If an attacker steals RT1, but the user is already using RT2 → the system detects the attack.


🧱 Correct database schema

A Refresh Token must be stored in the DB. Simplified example:

CREATE TABLE RefreshTokens
(
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    UserId UNIQUEIDENTIFIER NOT NULL,
    TokenHash NVARCHAR(200) NOT NULL,
    CreatedAt DATETIME2 NOT NULL,
    ExpiresAt DATETIME2 NOT NULL,
    RevokedAt DATETIME2 NULL,
    ReplacedByTokenId UNIQUEIDENTIFIER NULL,
    CreatedByIp NVARCHAR(50)
);

Important:

✔ the token is hashed

✔ do not store the raw token


🔒 C# Model for Refresh Token

public class RefreshToken
{
    public Guid Id { get; set; }

    public Guid UserId { get; set; }

    public string TokenHash { get; set; }

    public DateTime CreatedAt { get; set; }

    public DateTime ExpiresAt { get; set; }

    public DateTime? RevokedAt { get; set; }

    public Guid? ReplacedByTokenId { get; set; }

    public string CreatedByIp { get; set; }

    public bool IsActive =>
        RevokedAt == null && DateTime.UtcNow < ExpiresAt;
}


🧠 Refresh Endpoint in ASP.NET Core

Simplified example:

[HttpPost("refresh")]
public async Task<IActionResult> Refresh(RefreshRequest request)
{
    var token = await _repo.GetByTokenHash(request.RefreshToken);

    if (token == null || !token.IsActive)
        return Unauthorized();

    var newAccessToken = _jwtService.GenerateAccessToken(token.UserId);

    var newRefreshToken = _tokenService.CreateRefreshToken(token.UserId);

    token.RevokedAt = DateTime.UtcNow;
    token.ReplacedByTokenId = newRefreshToken.Id;

    await _repo.SaveChanges();

    return Ok(new
    {
        accessToken = newAccessToken,
        refreshToken = newRefreshToken.Token
    });
}


🚨 Detecting Reuse Attack

A reuse attack occurs when a refresh token already invalidated is used again.

Scenario:

1️⃣ User receives RT1

2️⃣ Attacker steals RT1

3️⃣ User uses RT1 → receives RT2

4️⃣ Attacker tries RT1

The server detects:

<code>RT1.RevokedAt != null</code>

Recommended action:

  • revoke all user's tokens

  • force re-authentication


⏳ Sliding vs Absolute Expiration

There are two strategies.


Sliding Expiration

The token is extended at each refresh.

Example:

Refresh token = 7 days
User refreshes daily
→ the token remains valid

Advantage:

✔ good user experience

Disadvantage: ⚠ very long sessions


Absolute Expiration

The token expires definitively after a fixed period. Example:

Max lifetime = 30 days

Even if you refresh: → after 30 days you must log in.


🛡️ Best Practices for Refresh Tokens

✔ Hash token in DB

✔ Mandatory token rotation

✔ Revocation list

✔ Detect reuse attack

✔ IP logging

✔ Device tracking

✔ Limited expiration

✔ Long random token (256 bits)


🔐 Where to store Refresh Token on the client?

Recommended:

HttpOnly Secure Cookie

Not in:

❌ LocalStorage

❌ SessionStorage

Reason: XSS can steal the token.


🧱 Modern Architecture

Client (SPA / Blazor)

Access Token -> memory
Refresh Token -> HttpOnly cookie

Server:

JWT stateless
Refresh Tokens stateful (DB)

This is the model used by most enterprise systems.


🎯 Conclusion

JWT alone is not enough. Refresh Tokens solve:

✔ short token expiration

✔ persistent sessions

✔ access revocation

✔ attack detection

But only if implemented correctly.