REST Minimal API with Authentication

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.