Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETJWTAuthenticationRefresh TokensSecurity
Share:𝕏

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

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

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

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

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

C#
// 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 to TimeSpan.Zero if you need precise expiry enforcement.

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

C#
// 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 RevokedAt column, 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.

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.