An HTTP API that can be called without proving who is calling is a public API, even if you “didn’t document it.” Authentication answers who; authorization answers what they may do. In ASP.NET Core Minimal APIs both are middleware + metadata on endpoints, not a custom if (token == "secret").
Pick a scheme and stay boring
- Cookie + session (or cookie + ASP.NET Core Identity) for first-party browsers.
HttpOnly,Secure,SameSite. CSRF tokens if the cookie is sent automatically. - Bearer JWT for service-to-service or SPA + API when you accept token theft as a design constraint. Validate issuer, audience, lifetime, signing key. Do not roll your own JWT parser.
- Mutual TLS or signed requests for high-value machine clients.
Do not put long-lived JWTs in localStorage and call it security. Do not log Authorization headers.
Wire-up shape
The HMAC key comes from configuration. Use the asymmetric key you actually issue when that is how the tokens are signed. A missing Jwt:SigningKey fails startup. A policy name that was never registered fails the request; it does not mean “any logged-in user.”
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
options.MapInboundClaims = false; // so "sub" stays "sub"
var key = builder.Configuration["Jwt:SigningKey"];
if (string.IsNullOrWhiteSpace(key))
throw new InvalidOperationException("Jwt:SigningKey is required.");
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("orders.read", policy => policy.RequireClaim("scope", "orders.read"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
var orders = app.MapGroup("/orders").RequireAuthorization("orders.read");
orders.MapGet("/{id:guid}", GetOrder);
RequireAuthorization() with no policy still demands an authenticated user. Policies encode roles/claims (“must be in tenant X”). Resource-based checks (this order belongs to that user) happen in the handler, not only in a global role.
What breaks it
GetAnyOrder returns any order to any authenticated caller. GetOrder compares CustomerId to sub (claim mapping is off in the wire-up above) and returns 403 when they differ.
static async Task<IResult> GetAnyOrder(Guid id, IOrders orders, CancellationToken ct)
{
var order = await orders.Get(id, ct);
return order is null ? Results.NotFound() : Results.Ok(order);
}
static async Task<IResult> GetOrder(HttpContext http, Guid id, IOrders orders, CancellationToken ct)
{
var order = await orders.Get(id, ct);
if (order is null)
return Results.NotFound();
var sub = http.User.FindFirstValue("sub");
if (sub is null || order.CustomerId != sub)
return Results.Forbid();
return Results.Ok(order);
}
Pitfalls
- Authentication without authorization: any logged-in user can hit
/admin. - Failing open when the identity service is down.
- Using GET with a token in the query string (logs, Referer).
- Confusing 401 (no/invalid credentials) with 403 (credentials fine, not allowed).
See OWASP Top Ten A07 Authentication Failures and A01 Broken Access Control.