RequireAuthorization and [Authorize]
Protect endpoints and route groups with RequireAuthorization or [Authorize], open them with AllowAnonymous, and secure everything by default.
builder.Services.AddAuthorizationBuilder().SetFallbackPolicy(
new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
app.MapGroup("/admin").RequireAuthorization("BackOffice");
app.MapGet("/health", () => "ok").AllowAnonymous();Authorization runs in AuthorizationMiddleware, after routing has picked an endpoint and after authentication has set HttpContext.User. The middleware reads the endpoint's metadata, combines every rule it finds into one AuthorizationPolicy and evaluates it before the handler runs. WebApplication adds UseAuthentication and UseAuthorization for you when the services are registered; if you add them yourself they belong after UseRouting and UseCors and before the endpoints, or the middleware sees no endpoint and enforces nothing but the fallback policy.
Where rules attach
.RequireAuthorization()on an endpoint uses the default policy;.RequireAuthorization("BackOffice")names a policy;.RequireAuthorization(p => p.RequireRole("admin"))builds one inline.app.MapGroup("/admin").RequireAuthorization(...)applies to every endpoint in the group, including ones mapped later from other files.- Controllers use
[Authorize]on the class or the action. Both forms produce the sameIAuthorizeDatametadata. AllowAnonymous()or[AllowAnonymous]addsIAllowAnonymous, and the middleware skips evaluation entirely. It wins over every rule on the endpoint, whether the rule sits on the group or the endpoint and in either order.
How rules combine
| You write | Effect |
|---|---|
Two [Authorize] attributes or two RequireAuthorization calls | Both must pass (AND) |
Roles = "support,admin" or RequireRole("support", "admin") | Any one role (OR) |
| Group rule plus endpoint rule | Both must pass (AND) |
AllowAnonymous anywhere on the endpoint | No authorization at all |
Default policy and fallback policy
DefaultPolicy is what a bare [Authorize] or RequireAuthorization() means; out of the box it only requires an authenticated user. FallbackPolicy applies to endpoints with no authorization metadata at all, and it is null by default: an endpoint nobody annotated is public. Set the fallback policy to RequireAuthenticatedUser() and the API is secure by default; anything public must then say AllowAnonymous, which is easy to grep and review. The fallback also covers requests that match no endpoint, so an anonymous caller gets 401 for an unknown URL instead of 404, as the demo shows.
401 or 403
If the policy fails and the user is not authenticated, the middleware challenges the scheme: 401, with WWW-Authenticate: Bearer for JWT. If the user is authenticated but fails the policy, it forbids: 403. Cookie authentication used to redirect to a login page instead; in .NET 10 it returns 401 and 403 for known API endpoints.
Production failure modes
- A new endpoint ships without
RequireAuthorizationand is public. The fallback policy closes this whole class of bug. - After turning on the fallback policy, Kubernetes probes and
/openapi/v1.jsonstart getting 401: a 401 on the liveness probe restarts pods in a loop, and a 401 on the readiness probe takes every pod out of the Service. Mark probe endpointsAllowAnonymouson purpose. - A group marked
AllowAnonymousas the "public" bucket silently overrides aRequireAuthorizationadded to one of its endpoints later. - Checks written as
ifstatements inside handlers are invisible to the middleware, to OpenAPI and to an endpoint inventory test. Keep route-level rules in metadata.
Example
When to use it
- Turn on a fallback policy so a newly added GET /orders/export cannot ship without authentication by accident
- Protect every /admin endpoint with one RequireAuthorization("BackOffice") on the route group
- Keep /health/live and /health/ready reachable by Kubernetes probes while the rest of the API needs a token
More examples
The same rules on a controller
Attributes on the class and the action are all collected into the endpoint's metadata and combined with AND, so <code>Refund</code> needs an authenticated user, the <code>finance</code> role and the <code>RefundApprover</code> policy. Inside a single <code>Roles</code> string the comma means OR. <code>[AllowAnonymous]</code> on <code>Statuses</code> beats the class-level <code>[Authorize]</code>; the reverse is also true, so an <code>[AllowAnonymous]</code> on the class would make every action public.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Shop.Api.Controllers;
public sealed record RefundRequest(decimal Amount, string Reason);
[ApiController]
[Route("api/orders")]
[Authorize]
public sealed class OrdersController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult Get(int id) => Ok(new { id, status = "Placed" });
// One attribute, two roles: support OR admin.
[HttpGet("{id:int}/audit")]
[Authorize(Roles = "support,admin")]
public IActionResult Audit(int id) => Ok(new { id, entries = 3 });
// Two attributes: the finance role AND the RefundApprover policy, on top of the class-level [Authorize].
[HttpPost("{id:int}/refunds")]
[Authorize(Roles = "finance")]
[Authorize(Policy = "RefundApprover")]
public IActionResult Refund(int id, RefundRequest request) => Accepted();
[HttpGet("~/api/order-statuses")]
[AllowAnonymous]
public IActionResult Statuses() => Ok(new[] { "Placed", "Shipped", "Cancelled" });
}Fallback policy with deliberate exemptions
With the fallback policy on, every route that is not explicitly opened needs a valid token, including the health checks and the OpenAPI document. Opening them with <code>AllowAnonymous()</code> right where they are mapped keeps the exemptions in one visible place. The OpenAPI document is mapped only in Development, so production does not publish the API surface to anonymous callers.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
// Authority and audience come from Authentication:Schemes:Bearer in configuration.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
builder.Services.AddHealthChecks();
builder.Services.AddOpenApi();
var app = builder.Build();
// Probes run without a token: a 401 on liveness restarts pods in a loop, a 401 on readiness takes every pod out of the Service.
app.MapHealthChecks("/health/live").AllowAnonymous();
app.MapHealthChecks("/health/ready").AllowAnonymous();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().AllowAnonymous();
}
// No metadata: protected by the fallback policy.
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id, status = "Placed" }));
app.Run();A default policy tied to one scheme
A policy that lists authentication schemes authenticates the request with exactly those schemes and replaces <code>HttpContext.User</code> with the result. That is how one API accepts two issuers without a partner token working on first-party endpoints. Keep the number of policies that name schemes small: every one of them must be adjusted when tests swap in a fake scheme.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer()
// Partners get tokens from a different issuer; configured under Authentication:Schemes:PartnerBearer.
.AddJwtBearer("PartnerBearer");
builder.Services.AddAuthorizationBuilder()
// A bare [Authorize] or RequireAuthorization() now accepts only first-party tokens.
.SetDefaultPolicy(new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build())
.AddPolicy("Partner", p => p
.AddAuthenticationSchemes("PartnerBearer")
.RequireAuthenticatedUser()
.RequireClaim("partner_id"));
var app = builder.Build();
app.MapGet("/orders", () => TypedResults.Ok(new[] { 1042, 1043 })).RequireAuthorization();
app.MapGet("/partner/inventory", () => TypedResults.Ok(new { sku = "KT-200", onHand = 48 }))
.RequireAuthorization("Partner");
app.Run();
Discussion