JSON Web Tokens (JWT) have become the de facto standard for securing modern web APIs due to their stateless nature and scalability. However, a common architectural pitfall is issuing long-lived access tokens. If an access token is intercepted, an attacker retains full API access until the token expires.

To solve this, production systems implement short-lived access tokens paired with Secure Refresh Token Rotation. This pattern ensures sessions remain active without compromising security, instantly detecting and neutralizing token theft attempts.

This article walks through a complete, production-grade implementation of JWT authentication with refresh token rotation in an ASP.NET Core Web API.

1. Architectural Strategy: Access vs. Refresh Tokens

2. Step 1: Configuring JWT Settings & Models

First, define your JWT configuration settings in appsettings.json:

JSON

{
  "JwtSettings": {
    "Secret": "SuperSecretKeyWithAtLeast32CharactersMinForHMACSHA256",
    "Issuer": "MyAppApi",
    "Audience": "MyAppClients",
    "AccessTokenExpirationMinutes": 15,
    "RefreshTokenExpirationDays": 7
  }
}

Create a corresponding model to bind these options:

C#

// Application/Common/Models/JwtSettings.cs
namespace Application.Common.Models;

public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int AccessTokenExpirationMinutes { get; set; }
    public int RefreshTokenExpirationDays { get; set; }
}

3. Step 2: Designing the Refresh Token Entity

The database must track refresh tokens to manage validation, expiration, and revocation chains.

C#

// Domain/Entities/RefreshToken.cs
namespace Domain.Entities;

public class RefreshToken
{
    public int Id { get; set; }
    public string UserId { get; set; } = string.Empty;
    public string Token { get; set; } = string.Empty;
    public DateTime ExpiresAt { get; set; }
    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? RevokedAt { get; set; }
    public string? ReplacedByToken { get; set; }
    public bool IsActive => RevokedAt == null && !IsExpired;
}

4. Step 3: Implementing the Token Generation Service

This service handles signing access tokens using symmetric keys and generating cryptographically secure refresh tokens.

C#

// Infrastructure/Authentication/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Application.Common.Models;
using Domain.Entities;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace Infrastructure.Authentication;

public class TokenService
{
    private readonly JwtSettings _jwtSettings;

    public TokenService(IOptions<JwtSettings> jwtSettings)
    {
        _jwtSettings = jwtSettings.Value;
    }

    public string GenerateAccessToken(IEnumerable<Claim> claims)
    {
        var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret);
        var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes),
            Issuer = _jwtSettings.Issuer,
            Audience = _jwtSettings.Audience,
            SigningCredentials = credentials
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    public RefreshToken GenerateRefreshToken(string userId)
    {
        var randomNumber = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomNumber);

        return new RefreshToken
        {
            Token = Convert.ToBase64String(randomNumber),
            UserId = userId,
            ExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays),
            CreatedAt = DateTime.UtcNow
        };
    }
}

5. Step 4: Implementing Refresh Token Rotation Logic

When a client requests a new access token using an existing refresh token, the rotation service validates the token, flags it as replaced, and issues a fresh pair.

C#

// Application/Authentication/Commands/RefreshTokenCommandHandler.cs
using Application.Common.Models;
using Infrastructure.Authentication;
using Infrastructure.Persistence;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Application.Authentication.Commands;

public record RefreshTokenCommand(string Token) : IRequest<AuthResponseDto>;

public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, AuthResponseDto>
{
    private readonly AppDbContext _context;
    private readonly TokenService _tokenService;

    public RefreshTokenCommandHandler(AppDbContext context, TokenService tokenService)
    {
        _context = context;
        _tokenService = tokenService;
    }

    public async Task<AuthResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
    {
        var existingToken = await _context.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.Token == request.Token, cancellationToken);

        if (existingToken == null || !existingToken.IsActive)
        {
            // SECURITY ALERT: If an inactive token is reused, revoke all tokens for this user family
            if (existingToken != null)
            {
                await RevokeDescendantTokensAsync(existingToken.UserId, cancellationToken);
            }
            throw new UnauthorizedAccessException("Invalid or revoked refresh token.");
        }

        // Generate new token rotation pair
        var newRefreshToken = _tokenService.GenerateRefreshToken(existingToken.UserId);
        
        existingToken.RevokedAt = DateTime.UtcNow;
        existingToken.ReplacedByToken = newRefreshToken.Token;

        _context.RefreshTokens.Add(newRefreshToken);
        await _context.SaveChangesAsync(cancellationToken);

        // Fetch user claims and generate new access token
        // (Simplified for demonstration)
        var accessToken = _tokenService.GenerateAccessToken(new[] { new Claim(ClaimTypes.NameIdentifier, existingToken.UserId) });

        return new AuthResponseDto(accessToken, newRefreshToken.Token);
    }

    private async Task RevokeDescendantTokensAsync(string userId, CancellationToken cancellationToken)
    {
        var activeTokens = await _context.RefreshTokens
            .Where(rt => rt.UserId == userId && rt.RevokedAt == null)
            .ToListAsync(cancellationToken);

        foreach (var token in activeTokens)
        {
            token.RevokedAt = DateTime.UtcNow;
        }
        await _context.SaveChangesAsync(cancellationToken);
    }
}

public record AuthResponseDto(string AccessToken, string RefreshToken);

6. Step 5: Exposing Secure Endpoints

Bind the refresh token securely into an HttpOnly cookie inside the API controller, preventing client-side JavaScript access.

C#

// WebApi/Controllers/AuthController.cs
using Application.Authentication.Commands;
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace WebApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly ISender _sender;

    public AuthController(ISender sender)
    {
        _sender = sender;
    }

    [HttpPost("refresh-token")]
    public async Task<IActionResult> RefreshToken(CancellationToken cancellationToken)
    {
        var refreshToken = Request.Cookies["refreshToken"];
        if (string.IsNullOrEmpty(refreshToken))
            return BadRequest(new { message = "Refresh token is missing." });

        try
        {
            var result = await _sender.Send(new RefreshTokenCommand(refreshToken), cancellationToken);

            // Set the new rotated refresh token in a secure HttpOnly cookie
            SetRefreshTokenCookie(result.RefreshToken);

            return Ok(new { result.AccessToken });
        }
        catch (UnauthorizedAccessException ex)
        {
            return Unauthorized(new { message = ex.Message });
        }
    }

    private void SetRefreshTokenCookie(string token)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Strict,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    }
}

Conclusion

By combining short-lived JWT access tokens with rotating HttpOnly refresh tokens, your ASP.NET Core Web API achieves high resilience against token theft, session hijacking, and cross-site scripting (XSS) vectors.