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
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
dotnet add package BCrypt.Net-NextConfiguration
// 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
}
}// 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
// 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
// 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
// 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?
// 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
// 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
// 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
// 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?
// 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
// 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?
// 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.
