JSON Web Tokens are the de facto standard for securing APIs. ASP.NET Core has excellent built-in support for JWT bearer authentication, but getting the configuration right — and understanding what each setting actually does — takes some care. Let's walk through a complete implementation.
Setting Up JWT Authentication
First, add the required package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Then configure authentication in Program.cs:
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 = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
Every one of those Validate flags matters. Disabling ValidateLifetime, for instance, means expired tokens are still accepted — a genuine security hole that crops up in production more often than you'd think.
Generating Tokens
A typical token generation service looks like this:
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateToken(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Role, user.Role)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Note the short expiry of 15 minutes. Short-lived access tokens limit the damage if a token is compromised.
The Login Endpoint
app.MapPost("/api/auth/login", async (LoginRequest request,
UserService userService, TokenService tokenService) =>
{
var user = await userService.ValidateCredentialsAsync(
request.Email, request.Password);
if (user is null)
return Results.Unauthorized();
var accessToken = tokenService.GenerateToken(user);
var refreshToken = tokenService.GenerateRefreshToken();
await userService.StoreRefreshTokenAsync(user.Id, refreshToken);
return Results.Ok(new { accessToken, refreshToken });
});
Refresh Tokens
Access tokens should be short-lived, but you don't want users logging in every 15 minutes. Refresh tokens solve this. They're long-lived, opaque strings stored server-side that can be exchanged for a new access token:
app.MapPost("/api/auth/refresh", async (RefreshRequest request,
UserService userService, TokenService tokenService) =>
{
var user = await userService.GetByRefreshTokenAsync(request.RefreshToken);
if (user is null)
return Results.Unauthorized();
// Rotate the refresh token on each use
var newAccessToken = tokenService.GenerateToken(user);
var newRefreshToken = tokenService.GenerateRefreshToken();
await userService.RevokeRefreshTokenAsync(request.RefreshToken);
await userService.StoreRefreshTokenAsync(user.Id, newRefreshToken);
return Results.Ok(new { accessToken: newAccessToken, refreshToken: newRefreshToken });
});
Rotating refresh tokens on every use is important. If an attacker steals a refresh token and the legitimate user tries to use the old one, you'll detect the reuse and can revoke the entire family of tokens.
Protecting Endpoints
With authentication configured, securing endpoints is straightforward:
app.MapGet("/api/profile", (ClaimsPrincipal user) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Results.Ok(new { userId });
}).RequireAuthorization();
Common Mistakes to Avoid
Storing tokens in localStorage. This makes them accessible to any JavaScript running on the page, including XSS payloads. HttpOnly cookies are safer for browser-based applications.
Using weak signing keys. Your HMAC key should be at least 256 bits of cryptographically random data, not a short passphrase.
Not validating all token parameters. Every Validate flag in TokenValidationParameters exists for a reason. Disabling them "to get things working" and forgetting to re-enable them is a classic vulnerability.
Ignoring token revocation. JWTs are stateless by design, which means you can't revoke them mid-flight. Keep expiry times short, use refresh tokens, and consider a token blacklist for immediate revocation when needed.
Wrapping Up
JWT authentication in ASP.NET Core is well-supported and relatively straightforward to implement. The key is understanding each configuration option and making deliberate choices about token lifetimes, storage, and rotation. Short-lived access tokens paired with rotating refresh tokens give you a solid foundation that balances security with usability.