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
Access Token: Short-lived (e.g., 5 to 15 minutes), cryptographically signed JWT containing user claims and roles. Sent with every API request via the
Authorization: Bearerheader.Refresh Token: Long-lived (e.g., 7 to 30 days), cryptographically secure random string stored securely in the database and delivered to the client via an HttpOnly, Secure Cookie to prevent XSS theft.
Token Rotation: Every time a refresh token is used to obtain a new access token, the old refresh token is invalidated and a brand-new refresh token is issued. If an old, already-used refresh token is presented, the system triggers a thef detection alert and revokes the entire token family.
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.

Join the conversation! Your thoughts help the community grow.