Minimal APIs removed a lot of ceremony from ASP.NET Core, but authentication is one area where the reduced ceremony makes it easier to ship something insecure without noticing. This post walks through a production-shaped setup.
What does JWT bearer authentication actually validate?
A bearer token is only as good as the checks you run against it. On every request, ASP.NET Core verifies four things: the signature (the token was issued by someone holding the signing key), the issuer (iss), the audience (aud), and the lifetime (exp/nbf). Disable any one of these and the token stops being a security boundary.
Here is the minimum viable registration.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(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"]!)),
// Default is 5 minutes of leeway. Tighten it.
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
Every one of those four Validate* flags is set explicitly on purpose. They default to true, but writing them out means a future edit that flips one is visible in code review rather than buried in framework defaults.
How do you apply authorization to endpoints?
Minimal APIs replace [Authorize] with a fluent call. The middleware order is not optional:
var app = builder.Build();
app.UseAuthentication(); // must come first — it populates HttpContext.User
app.UseAuthorization(); // then this decides whether that user is allowed
app.MapGet("/public", () => "anyone can read this");
app.MapGet("/me", (ClaimsPrincipal user) => new
{
Name = user.Identity?.Name,
Email = user.FindFirstValue(ClaimTypes.Email)
})
.RequireAuthorization();
app.Run();
Reverse those two Use calls and HttpContext.User is still anonymous when authorization runs, so every protected endpoint returns 401 regardless of the token. It is the single most common setup bug.
Grouping protected endpoints
For anything beyond a couple of routes, apply the policy once to a route group:
var admin = app.MapGroup("/admin")
.RequireAuthorization("AdminOnly")
.WithTags("Admin");
admin.MapGet("/users", GetUsers);
admin.MapDelete("/users/{id:guid}", DeleteUser);
This is safer than per-endpoint calls: a new endpoint added to the group inherits protection by default, rather than being unprotected until someone remembers.
Role and policy-based authorization
Register named policies at startup and reference them by name:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("VerifiedEmail", policy =>
policy.RequireClaim("email_verified", "true"));
});
Prefer policies over raw role strings scattered through endpoints. When the rule changes — say verified email also requires MFA — you edit one registration instead of hunting every call site.
Common configuration mistakes
| Mistake | Symptom | Fix |
|---|---|---|
UseAuthorization() before UseAuthentication() | Every protected route 401s | Swap the order |
| Signing key under 256 bits | IDX10653 at startup | Use a 32+ byte key for HS256 |
Key committed to appsettings.json | Token forgery if repo leaks | Use user-secrets locally, Key Vault in Azure |
ValidateAudience = false | Tokens from another app are accepted | Always validate, set ValidAudience |
Default ClockSkew | Expired tokens work for 5 more minutes | Set to 30 seconds or less |
Debugging a 401 you cannot explain
Turn on personally-identifiable logging in development only. It reveals exactly which validation step failed:
if (app.Environment.IsDevelopment())
{
IdentityModelEventSource.ShowPII = true;
}
The log will name the failing parameter — issuer mismatch, audience mismatch, or signature failure — which turns a guessing game into a one-line fix.
Key takeaways
- Set all four
Validate*parameters explicitly so defaults never hide a weakened check. UseAuthentication()always precedesUseAuthorization().- Apply
.RequireAuthorization()to route groups so new endpoints are secure by default. - Keep signing keys in user-secrets or Key Vault, never in
appsettings.json. - Tighten
ClockSkew— the 5-minute default extends the life of every expired token.