JWT Authentication
Password hashing, token generation, login, and the supporting API config (rate limiting, Swagger bearer auth) as one self-contained flow.
Password hashing behind an interface
Wrapping BCrypt behind IPassword keeps the hashing library swappable and makes the auth service trivially mockable in tests.
public interface IPassword
{
bool Validate(string hash, string password);
string Hash(string password);
}
public class Password : IPassword
{
public bool Validate(string hash, string password)
=> BCrypt.Net.BCrypt.Verify(password, hash);
public string Hash(string password)
=> BCrypt.Net.BCrypt.HashPassword(password);
}
Token generation
Config values (key, issuer, expiry) are validated up front so a misconfigured environment fails loudly at first use instead of producing a broken, unverifiable token.
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config) => _config = config;
public Token GenerateToken(User user)
{
var claims = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.FullName ?? ""),
new Claim(ClaimTypes.Role, user.Role.ToString())
});
string key = _config["JWT:Key"] ?? throw new ArgumentException("JWT key missing");
string issuer = _config["JWT:Issuer"] ?? throw new ArgumentException("JWT issuer missing");
double expiryMinutes = double.Parse(_config["JWT:ExpiryMinutes"]!);
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var expiresOn = DateTime.UtcNow.AddMinutes(expiryMinutes);
var descriptor = new SecurityTokenDescriptor
{
Issuer = issuer,
Subject = claims,
Expires = expiresOn,
SigningCredentials = credentials
};
return new Token
{
JWT = new JsonWebTokenHandler().CreateToken(descriptor),
ExpiresOn = expiresOn
};
}
}
Login flow
public class AuthService : IAuthService
{
private readonly IUserRepository _users;
private readonly ITokenService _tokens;
private readonly IPassword _password;
public AuthService(IUserRepository users, ITokenService tokens, IPassword password)
{
_users = users; _tokens = tokens; _password = password;
}
public async Task<Token> LoginAsync(LoginRequest request)
{
var user = await _users.GetByEmailAsync(request.Email);
if (user is null || !_password.Validate(user.HashedPassword, request.Password))
throw new AuthenticationException("Email or password is incorrect.");
return _tokens.GenerateToken(user);
}
}
API wiring: JWT bearer, rate limiter, Swagger security
All three registered together in one RegisterAPI extension (see Layered Architecture). The rate limiter partitions by user ID when authenticated, falling back to IP for anonymous requests. Both the auth failures below and the rate limiter's rejection are wired to return the same ApiResponse envelope as every other endpoint, so the frontend's error handling never needs a special case for "the request got rejected before it reached a controller."
private static IServiceCollection RegisterAuthentication(this IServiceCollection services, IConfiguration config)
{
string issuer = config["JWT:Issuer"] ?? "";
string key = config["JWT:Key"] ?? "";
services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ClockSkew = TimeSpan.Zero
};
// without this, an unauthenticated or unauthorized request gets an
// empty 401/403 body — JwtBearerEvents lets it return the same
// ApiResponse envelope as every other endpoint instead
options.Events = new JwtBearerEvents
{
OnChallenge = async context =>
{
context.HandleResponse(); // skip the default empty-body response
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
var response = ApiResponse<object>.Fail("You are not logged in.");
await context.Response.WriteAsJsonAsync(response);
},
OnForbidden = async context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
var response = ApiResponse<object>.Fail("You don't have access to this.");
await context.Response.WriteAsJsonAsync(response);
}
};
});
services.AddAuthorization();
return services;
}
private static IServiceCollection RegisterRateLimiter(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.Connection.RemoteIpAddress?.ToString()
?? "anonymous",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 50,
AutoReplenishment = true,
Window = TimeSpan.FromMinutes(1)
}));
// same ApiResponse envelope used everywhere else — a 429 shouldn't look
// different from any other failure response the frontend already handles
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.ContentType = "application/json";
var response = ApiResponse<object>.Fail("Rate limit exceeded. Try again later.");
await context.HttpContext.Response.WriteAsJsonAsync(response, cancellationToken);
};
});
return services;
}
private static IServiceCollection RegisterSwagger(this IServiceCollection services)
{
services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT"
});
options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
});
});
return services;
}