JWT Authentication in ASP.NET Core: Complete Security Guide
Backend15 min read

JWT Authentication in ASP.NET Core: Complete Security Guide

Implement secure JWT authentication with refresh tokens, role-based authorization, and security best practices in ASP.NET Core.

Taha Kocal

Taha Kocal

Full Stack Developer

Feb 12, 2025
#ASP.NET Core#JWT#Authentication#Security#C#

JWT (JSON Web Token) authentication is the standard approach for securing ASP.NET Core APIs: the server issues a digitally signed, self-contained token after login, and the client sends it in the Authorization header of every request, letting the API verify identity without a server-side session store. A JWT carries claims - user id, email, roles - signed with a secret key using an algorithm such as HMAC SHA-256, so any tampering invalidates the signature. In production, access tokens are kept short-lived (commonly 15 minutes) and paired with longer-lived refresh tokens (often 7 days) that can be rotated and revoked, limiting the damage of a stolen token. In this guide we implement a complete authentication system in ASP.NET Core: JWT generation and validation, user registration with BCrypt password hashing, a refresh token flow with rotation, role-based authorization, and the security best practices that keep it safe.

Setup

bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
dotnet add package BCrypt.Net-Next

Configuration

json
// appsettings.json
{
  "JwtSettings": {
    "SecretKey": "your-256-bit-secret-key-here-make-it-long-and-random",
    "Issuer": "https://yourapi.com",
    "Audience": "https://yourapi.com",
    "AccessTokenExpirationMinutes": 15,
    "RefreshTokenExpirationDays": 7
  }
}
csharp
// Infrastructure/Identity/JwtSettings.cs
public class JwtSettings
{
    public string SecretKey { 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; }
}

JWT Service

csharp
// Application/Interfaces/IJwtService.cs
public interface IJwtService
{
    string GenerateAccessToken(User user);
    RefreshToken GenerateRefreshToken();
    ClaimsPrincipal? GetPrincipalFromExpiredToken(string token);
}

// Infrastructure/Identity/JwtService.cs
public class JwtService : IJwtService
{
    private readonly JwtSettings _settings;
    private readonly SymmetricSecurityKey _key;

    public JwtService(IOptions<JwtSettings> settings)
    {
        _settings = settings.Value;
        _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey));
    }

    public string GenerateAccessToken(User user)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Email, user.Email),
            new(ClaimTypes.Name, user.FullName),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };

        // Add roles
        foreach (var role in user.Roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role.Name));
        }

        var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _settings.Issuer,
            audience: _settings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

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

        return new RefreshToken
        {
            Token = Convert.ToBase64String(randomBytes),
            ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpirationDays),
            CreatedAt = DateTime.UtcNow
        };
    }

    public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
    {
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = false, // We want to validate expired tokens
            ValidateIssuerSigningKey = true,
            ValidIssuer = _settings.Issuer,
            ValidAudience = _settings.Audience,
            IssuerSigningKey = _key
        };

        var tokenHandler = new JwtSecurityTokenHandler();

        try
        {
            var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var securityToken);

            if (securityToken is not JwtSecurityToken jwtSecurityToken ||
                !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
            {
                return null;
            }

            return principal;
        }
        catch
        {
            return null;
        }
    }
}

User and RefreshToken Entities

csharp
// Domain/Entities/User.cs
public class User : BaseEntity
{
    public Guid Id { get; set; }
    public string Email { get; set; } = string.Empty;
    public string FullName { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public bool IsActive { get; set; } = true;
    public DateTime? LastLoginAt { get; set; }

    public ICollection<Role> Roles { get; set; } = new List<Role>();
    public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}

// Domain/Entities/RefreshToken.cs
public class RefreshToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string Token { get; set; } = string.Empty;
    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? RevokedAt { get; set; }
    public string? ReplacedByToken { get; set; }
    public string? ReasonRevoked { get; set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsRevoked => RevokedAt != null;
    public bool IsActive => !IsRevoked && !IsExpired;

    public User User { get; set; } = null!;
}

// Domain/Entities/Role.cs
public class Role
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection<User> Users { get; set; } = new List<User>();
}

Authentication Service

csharp
// Application/Features/Auth/Commands/Login/LoginCommand.cs
public record LoginCommand(string Email, string Password) : IRequest<Result<AuthResponse>>;

// Application/Features/Auth/Commands/Login/LoginCommandHandler.cs
public class LoginCommandHandler : IRequestHandler<LoginCommand, Result<AuthResponse>>
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtService _jwtService;
    private readonly IUnitOfWork _unitOfWork;

    public LoginCommandHandler(
        IUserRepository userRepository,
        IJwtService jwtService,
        IUnitOfWork unitOfWork)
    {
        _userRepository = userRepository;
        _jwtService = jwtService;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<AuthResponse>> Handle(LoginCommand request, CancellationToken cancellationToken)
    {
        // Find user
        var user = await _userRepository.GetByEmailWithRolesAsync(request.Email, cancellationToken);
        if (user is null)
            return Result.Failure<AuthResponse>(AuthErrors.InvalidCredentials);

        // Verify password
        if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
            return Result.Failure<AuthResponse>(AuthErrors.InvalidCredentials);

        // Check if active
        if (!user.IsActive)
            return Result.Failure<AuthResponse>(AuthErrors.AccountDisabled);

        // Generate tokens
        var accessToken = _jwtService.GenerateAccessToken(user);
        var refreshToken = _jwtService.GenerateRefreshToken();

        // Save refresh token
        refreshToken.UserId = user.Id;
        user.RefreshTokens.Add(refreshToken);
        user.LastLoginAt = DateTime.UtcNow;

        // Remove old refresh tokens (keep last 5)
        var oldTokens = user.RefreshTokens
            .Where(t => !t.IsActive || t.CreatedAt < DateTime.UtcNow.AddDays(-7))
            .ToList();

        foreach (var oldToken in oldTokens)
        {
            user.RefreshTokens.Remove(oldToken);
        }

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(new AuthResponse
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken.Token,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15)
        });
    }
}

// Application/Features/Auth/DTOs/AuthResponse.cs
public record AuthResponse
{
    public string AccessToken { get; init; } = string.Empty;
    public string RefreshToken { get; init; } = string.Empty;
    public DateTime ExpiresAt { get; init; }
}

How Does the Refresh Token Flow Work?

csharp
// Application/Features/Auth/Commands/RefreshToken/RefreshTokenCommand.cs
public record RefreshTokenCommand(string AccessToken, string RefreshToken) : IRequest<Result<AuthResponse>>;

// Application/Features/Auth/Commands/RefreshToken/RefreshTokenCommandHandler.cs
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, Result<AuthResponse>>
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtService _jwtService;
    private readonly IUnitOfWork _unitOfWork;

    public RefreshTokenCommandHandler(
        IUserRepository userRepository,
        IJwtService jwtService,
        IUnitOfWork unitOfWork)
    {
        _userRepository = userRepository;
        _jwtService = jwtService;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<AuthResponse>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
    {
        // Validate expired access token
        var principal = _jwtService.GetPrincipalFromExpiredToken(request.AccessToken);
        if (principal is null)
            return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);

        var userId = Guid.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value);

        // Get user with refresh tokens
        var user = await _userRepository.GetByIdWithRefreshTokensAsync(userId, cancellationToken);
        if (user is null)
            return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);

        // Find the refresh token
        var existingRefreshToken = user.RefreshTokens
            .FirstOrDefault(t => t.Token == request.RefreshToken);

        if (existingRefreshToken is null)
            return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);

        // Handle token reuse (potential theft)
        if (existingRefreshToken.IsRevoked)
        {
            // Revoke all descendant tokens
            RevokeDescendantRefreshTokens(existingRefreshToken, user, "Attempted reuse of revoked token");
            await _unitOfWork.SaveChangesAsync(cancellationToken);
            return Result.Failure<AuthResponse>(AuthErrors.TokenReused);
        }

        if (!existingRefreshToken.IsActive)
            return Result.Failure<AuthResponse>(AuthErrors.InvalidToken);

        // Rotate refresh token
        var newRefreshToken = _jwtService.GenerateRefreshToken();
        newRefreshToken.UserId = user.Id;

        // Revoke old token
        existingRefreshToken.RevokedAt = DateTime.UtcNow;
        existingRefreshToken.ReplacedByToken = newRefreshToken.Token;
        existingRefreshToken.ReasonRevoked = "Rotated";

        user.RefreshTokens.Add(newRefreshToken);

        // Generate new access token
        var accessToken = _jwtService.GenerateAccessToken(user);

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(new AuthResponse
        {
            AccessToken = accessToken,
            RefreshToken = newRefreshToken.Token,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15)
        });
    }

    private void RevokeDescendantRefreshTokens(RefreshToken token, User user, string reason)
    {
        if (!string.IsNullOrEmpty(token.ReplacedByToken))
        {
            var childToken = user.RefreshTokens
                .FirstOrDefault(t => t.Token == token.ReplacedByToken);

            if (childToken != null)
            {
                if (childToken.IsActive)
                {
                    childToken.RevokedAt = DateTime.UtcNow;
                    childToken.ReasonRevoked = reason;
                }

                RevokeDescendantRefreshTokens(childToken, user, reason);
            }
        }
    }
}

Registration

csharp
// Application/Features/Auth/Commands/Register/RegisterCommand.cs
public record RegisterCommand : IRequest<Result<AuthResponse>>
{
    public string Email { get; init; } = string.Empty;
    public string Password { get; init; } = string.Empty;
    public string FullName { get; init; } = string.Empty;
}

// Application/Features/Auth/Commands/Register/RegisterCommandHandler.cs
public class RegisterCommandHandler : IRequestHandler<RegisterCommand, Result<AuthResponse>>
{
    private readonly IUserRepository _userRepository;
    private readonly IRoleRepository _roleRepository;
    private readonly IJwtService _jwtService;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<Result<AuthResponse>> Handle(RegisterCommand request, CancellationToken cancellationToken)
    {
        // Check if email exists
        var existingUser = await _userRepository.GetByEmailAsync(request.Email, cancellationToken);
        if (existingUser != null)
            return Result.Failure<AuthResponse>(AuthErrors.EmailTaken);

        // Get default role
        var userRole = await _roleRepository.GetByNameAsync("User", cancellationToken);

        // Create user
        var user = new User
        {
            Id = Guid.NewGuid(),
            Email = request.Email.ToLowerInvariant(),
            FullName = request.FullName,
            PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
            IsActive = true,
            Roles = new List<Role> { userRole! }
        };

        // Generate tokens
        var accessToken = _jwtService.GenerateAccessToken(user);
        var refreshToken = _jwtService.GenerateRefreshToken();
        refreshToken.UserId = user.Id;
        user.RefreshTokens.Add(refreshToken);

        await _userRepository.AddAsync(user, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success(new AuthResponse
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken.Token,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15)
        });
    }
}

Program.cs Configuration

csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// JWT Settings
var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>()!;
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));

// Authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = jwtSettings.Issuer,
        ValidAudience = jwtSettings.Audience,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
        ClockSkew = TimeSpan.Zero // No tolerance for token expiration
    };

    options.Events = new JwtBearerEvents
    {
        OnAuthenticationFailed = context =>
        {
            if (context.Exception is SecurityTokenExpiredException)
            {
                context.Response.Headers.Add("Token-Expired", "true");
            }
            return Task.CompletedTask;
        }
    };
});

// Authorization policies
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
    options.AddPolicy("ModeratorOrAdmin", policy => policy.RequireRole("Admin", "Moderator"));
});

// Register services
builder.Services.AddScoped<IJwtService, JwtService>();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

Auth Controller

csharp
// API/Controllers/AuthController.cs
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("register")]
    [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Register([FromBody] RegisterCommand command)
    {
        var result = await _mediator.Send(command);

        if (result.IsFailure)
            return BadRequest(result.Error);

        SetRefreshTokenCookie(result.Value.RefreshToken);
        return Ok(result.Value);
    }

    [HttpPost("login")]
    [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> Login([FromBody] LoginCommand command)
    {
        var result = await _mediator.Send(command);

        if (result.IsFailure)
            return Unauthorized(result.Error);

        SetRefreshTokenCookie(result.Value.RefreshToken);
        return Ok(result.Value);
    }

    [HttpPost("refresh-token")]
    [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
    {
        var refreshToken = request.RefreshToken ?? Request.Cookies["refreshToken"];

        if (string.IsNullOrEmpty(refreshToken))
            return Unauthorized("Refresh token is required");

        var result = await _mediator.Send(new RefreshTokenCommand(request.AccessToken, refreshToken));

        if (result.IsFailure)
            return Unauthorized(result.Error);

        SetRefreshTokenCookie(result.Value.RefreshToken);
        return Ok(result.Value);
    }

    [Authorize]
    [HttpPost("logout")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public async Task<IActionResult> Logout()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        if (!string.IsNullOrEmpty(refreshToken))
        {
            await _mediator.Send(new RevokeTokenCommand(refreshToken));
        }

        Response.Cookies.Delete("refreshToken");
        return Ok();
    }

    [Authorize]
    [HttpGet("me")]
    [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetCurrentUser()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var result = await _mediator.Send(new GetUserByIdQuery(Guid.Parse(userId!)));
        return Ok(result.Value);
    }

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

        Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
    }
}

How Do You Protect Endpoints with Roles?

csharp
// API/Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize] // Requires authentication
public class UsersController : ControllerBase
{
    [HttpGet]
    [Authorize(Roles = "Admin")] // Only admins
    public async Task<IActionResult> GetAllUsers() { }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetUser(Guid id) { }

    [HttpDelete("{id}")]
    [Authorize(Policy = "AdminOnly")]
    public async Task<IActionResult> DeleteUser(Guid id) { }
}

// Custom authorization attribute
public class RequireMinimumRoleAttribute : AuthorizeAttribute
{
    public RequireMinimumRoleAttribute(string role)
    {
        Roles = role;
    }
}

What Are JWT Security Best Practices?

Essential security measures:

  • Use HTTPS everywhere - never transmit tokens over HTTP
  • Store refresh tokens in HttpOnly cookies to prevent XSS attacks
  • Implement token rotation - issue new refresh token on each use
  • Keep access tokens short-lived (15 minutes or less)
  • Validate all token claims on the server
  • Implement rate limiting on auth endpoints
  • Log authentication events for auditing
csharp
// Additional security headers middleware
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
    await next();
});

// Rate limiting for auth endpoints
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("auth", config =>
    {
        config.Window = TimeSpan.FromMinutes(1);
        config.PermitLimit = 5; // 5 attempts per minute
        config.QueueLimit = 0;
    });
});

// Apply to auth endpoints
[EnableRateLimiting("auth")]
[HttpPost("login")]
public async Task<IActionResult> Login() { }

How Should You Hash Passwords?

csharp
// Password hashing with BCrypt
public class PasswordService : IPasswordService
{
    private const int WorkFactor = 12; // Adjust based on server capacity

    public string HashPassword(string password)
    {
        return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
    }

    public bool VerifyPassword(string password, string hash)
    {
        return BCrypt.Net.BCrypt.Verify(password, hash);
    }
}

// Password validation
public class PasswordValidator : AbstractValidator<string>
{
    public PasswordValidator()
    {
        RuleFor(password => password)
            .MinimumLength(8).WithMessage("Password must be at least 8 characters")
            .Matches(@"[A-Z]").WithMessage("Password must contain uppercase letter")
            .Matches(@"[a-z]").WithMessage("Password must contain lowercase letter")
            .Matches(@"[0-9]").WithMessage("Password must contain a number")
            .Matches(@"[^a-zA-Z0-9]").WithMessage("Password must contain special character");
    }
}

Security is not a feature, it's a requirement. Short-lived access tokens, secure refresh token storage, token rotation, and proper password hashing are non-negotiable. Always assume attackers are watching and design your auth system accordingly.

This JWT implementation provides a solid foundation for API security. Remember to regularly audit your authentication logs, implement account lockout policies, and stay updated on security best practices.

Share this article

Related Articles