JWT Authentication — Access Tokens, Refresh Tokens, and Endpoint Security
How to implement JWT authentication in Clean Architecture: login/register endpoints, short-lived access tokens, refresh token rotation, revocation, role-based authorization, and the production security mistakes to avoid.
The Token Strategy
Access token: JWT, signed with HMAC-SHA256, expires in 15 minutes
Refresh token: random 64-byte base64 string, stored in DB, expires in 7 days
Flow:
1. Client logs in → receives access token + refresh token
2. Client sends access token with every request (Authorization: Bearer)
3. Access token expires → client sends refresh token to /auth/refresh
4. Server validates refresh token → issues new access token + new refresh token
5. Old refresh token is marked revoked (rotation: one use only)Production security issue I've seen: A system issued refresh tokens that never expired and were never rotated. When a user's device was compromised, the attacker had indefinite access. Token rotation (invalidate on use, issue new) limits the window of exposure to the refresh token TTL.
Application Layer Contracts
// Application/Auth/Commands/Login/LoginCommand.cs
public sealed record LoginCommand(string Email, string Password) : ICommand<AuthResponse>;
// Application/Auth/Commands/Register/RegisterCommand.cs
public sealed record RegisterCommand(
string Email,
string Password,
string FirstName,
string LastName) : ICommand<AuthResponse>;
// Application/Auth/Commands/RefreshToken/RefreshTokenCommand.cs
public sealed record RefreshTokenCommand(
string AccessToken,
string RefreshToken) : ICommand<AuthResponse>;
// Application/Auth/Responses/AuthResponse.cs
public sealed record AuthResponse(
string AccessToken,
string RefreshToken,
DateTime RefreshTokenExpiry);Login Command Handler
// Application/Auth/Commands/Login/LoginCommandHandler.cs
public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, AuthResponse>
{
private readonly UserManager<AppUser> _users;
private readonly ITokenService _tokens;
private readonly AppDbContext _context;
public LoginCommandHandler(
UserManager<AppUser> users,
ITokenService tokens,
AppDbContext context)
{
_users = users;
_tokens = tokens;
_context = context;
}
public async Task<Result<AuthResponse>> Handle(
LoginCommand command, CancellationToken ct)
{
var user = await _users.FindByEmailAsync(command.Email);
if (user is null || !await _users.CheckPasswordAsync(user, command.Password))
return Result.Failure<AuthResponse>(AuthErrors.InvalidCredentials);
var roles = await _users.GetRolesAsync(user);
var accessToken = _tokens.GenerateAccessToken(user, roles);
var refreshToken = _tokens.GenerateRefreshToken();
refreshToken.UserId = user.Id;
_context.RefreshTokens.Add(refreshToken);
await _context.SaveChangesAsync(ct);
return Result.Success(new AuthResponse(
accessToken,
refreshToken.Token,
refreshToken.ExpiresAt));
}
}Refresh Token Handler
// Application/Auth/Commands/RefreshToken/RefreshTokenCommandHandler.cs
public sealed class RefreshTokenCommandHandler
: ICommandHandler<RefreshTokenCommand, AuthResponse>
{
private readonly UserManager<AppUser> _users;
private readonly ITokenService _tokens;
private readonly AppDbContext _context;
public RefreshTokenCommandHandler(
UserManager<AppUser> users,
ITokenService tokens,
AppDbContext context)
{
_users = users;
_tokens = tokens;
_context = context;
}
public async Task<Result<AuthResponse>> Handle(
RefreshTokenCommand command, CancellationToken ct)
{
// Validate the expired access token to extract the user
var principal = _tokens.GetPrincipalFromExpiredToken(command.AccessToken);
if (principal is null)
return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);
var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);
// Find the refresh token in DB
var storedToken = await _context.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t =>
t.Token == command.RefreshToken && t.UserId == userId, ct);
if (storedToken is null || !storedToken.IsActive)
return Result.Failure<AuthResponse>(AuthErrors.InvalidRefreshToken);
// Rotation: revoke old token, issue new one
storedToken.RevokedAt = DateTime.UtcNow;
var newRefreshToken = _tokens.GenerateRefreshToken();
newRefreshToken.UserId = userId;
storedToken.ReplacedByToken = newRefreshToken.Token;
_context.RefreshTokens.Add(newRefreshToken);
var roles = await _users.GetRolesAsync(storedToken.User);
var accessToken = _tokens.GenerateAccessToken(storedToken.User, roles);
await _context.SaveChangesAsync(ct);
return Result.Success(new AuthResponse(
accessToken,
newRefreshToken.Token,
newRefreshToken.ExpiresAt));
}
}Auth Controller
// Api/Controllers/AuthController.cs
[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private readonly LoginCommandHandler _login;
private readonly RegisterCommandHandler _register;
private readonly RefreshTokenCommandHandler _refresh;
public AuthController(
LoginCommandHandler login,
RegisterCommandHandler register,
RefreshTokenCommandHandler refresh)
{
_login = login;
_register = register;
_refresh = refresh;
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
{
var command = new LoginCommand(request.Email, request.Password);
var result = await _login.Handle(command, ct);
return result.Match<IActionResult>(Ok, err => err.ToProblemResult());
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct)
{
var command = new RegisterCommand(
request.Email, request.Password,
request.FirstName, request.LastName);
var result = await _register.Handle(command, ct);
return result.Match<IActionResult>(
auth => Ok(auth),
err => err.ToProblemResult());
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh(RefreshTokenRequest request, CancellationToken ct)
{
var command = new RefreshTokenCommand(request.AccessToken, request.RefreshToken);
var result = await _refresh.Handle(command, ct);
return result.Match<IActionResult>(Ok, err => err.ToProblemResult());
}
}Role-Based Authorization
// Seeding roles on startup
public static async Task SeedRolesAsync(RoleManager<IdentityRole> roleManager)
{
string[] roles = ["Admin", "Clinician", "Pharmacist", "ReadOnly"];
foreach (var role in roles)
{
if (!await roleManager.RoleExistsAsync(role))
await roleManager.CreateAsync(new IdentityRole(role));
}
}
// On a controller action
[Authorize(Roles = "Clinician,Admin")]
[HttpPost("{id:guid}/prescriptions")]
public async Task<IActionResult> AddPrescription(...) { ... }
[Authorize(Roles = "Pharmacist,Admin")]
[HttpPost("{id:guid}/dispense")]
public async Task<IActionResult> DispensingConfirm(...) { ... }
// Policy-based authorization (more flexible)
services.AddAuthorization(options =>
{
options.AddPolicy("CanPrescribe", policy =>
policy.RequireRole("Clinician", "Admin"));
options.AddPolicy("CanDispense", policy =>
policy.RequireRole("Pharmacist", "Admin"));
});PRO TIP — ClockSkew
By default, ASP.NET JWT validation has a 5-minute
ClockSkew. This means a token with a 15-minute expiry is actually valid for up to 20 minutes. Set it toTimeSpan.Zeroif you need precise expiry enforcement.
options.TokenValidationParameters = new TokenValidationParameters
{
// ... other settings
ClockSkew = TimeSpan.Zero, // no grace period
};Refresh Token Cleanup
Old revoked/expired refresh tokens accumulate in the database. Run a cleanup job periodically:
// Infrastructure/Jobs/RefreshTokenCleanupJob.cs
public sealed class RefreshTokenCleanupJob
{
private readonly AppDbContext _context;
public RefreshTokenCleanupJob(AppDbContext context) => _context = context;
public async Task RunAsync(CancellationToken ct)
{
var cutoff = DateTime.UtcNow.AddDays(-30);
await _context.RefreshTokens
.Where(t => t.ExpiresAt < cutoff || t.RevokedAt < cutoff)
.ExecuteDeleteAsync(ct);
}
}Red Flag Answers
Red flag: "My refresh tokens never expire and are not rotated."
Indefinite refresh tokens are a permanent credential. If leaked, the attacker owns the account forever.
Red flag: "I store the access token in localStorage."
XSS can read localStorage. Store access tokens in memory; store refresh tokens in HttpOnly cookies.
Green answer: "Access tokens expire in 15 minutes. Refresh tokens are single-use (rotated on each refresh), stored in the database with a
RevokedAtcolumn, and expire in 7 days. If a refresh token is presented after it's been revoked, the entire token family is considered compromised."
Key Takeaway
JWT is stateless for access tokens — fast and scalable. Refresh tokens are stateful — they live in the database so they can be revoked. The combination gives you the performance of stateless authentication and the security of revocable sessions. Token rotation limits the blast radius of a leaked refresh token to a single TTL window.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.