[Authorize(Roles = "Admin")] gets you surprisingly far, and then it stops working the moment "can edit orders" stops mapping cleanly onto a single role name. Policy-based authorization is what actually scales — roles become one input among several, not the whole model.
- Start with role checks for simple, coarse-grained access — they're readable and require no extra setup.
- Move to policies once a permission depends on more than one signal (role plus claim, or role plus resource ownership).
- Resource-based authorization — “can this user edit this specific order” — needs a handler that receives the resource, not just the current user.
- Authorization handlers are plain classes with no HTTP dependency, which means you can unit test them without spinning up the web pipeline.
Role checks: simple, coarse-grained
[Authorize(Roles = "Admin,Manager")]
public IActionResult ManageInventory() => View();
This works when the check really is just "does the caller hold one of these roles." It stops working cleanly once you need "Manager, but only for their own region" or "Admin, unless the account is suspended."
Policies: named, composable rules
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CanEditOrders", policy => policy
.RequireRole("Manager", "Admin")
.RequireClaim("department", "sales", "operations"))
.AddPolicy("CanViewBilling", policy => policy
.RequireAssertion(context =>
context.User.HasClaim("plan", "enterprise") ||
context.User.IsInRole("Admin")));
// Usage
app.MapGet("/orders/{id}/edit", EditOrder).RequireAuthorization("CanEditOrders");
A policy is just a named bundle of requirements. RequireAssertion covers ad hoc logic; for anything you'll reuse across multiple policies, write a custom requirement instead.
Resource-based authorization: checking against a specific object
Policies alone only see the current user's claims — they don't know which order you're trying to edit. For "can this user edit this order," you need a handler that receives the resource and checks it against the user, invoked explicitly from your endpoint or controller.
public class OrderOwnerRequirement : IAuthorizationRequirement { }
public class OrderOwnerHandler : AuthorizationHandler<OrderOwnerRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OrderOwnerRequirement requirement, Order resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (resource.CustomerId.ToString() == userId || context.User.IsInRole("Admin"))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// In an endpoint or controller action
var order = await _orders.GetAsync(orderId);
var result = await _authorizationService.AuthorizeAsync(User, order, "OrderOwner");
if (!result.Succeeded) return Forbid();
AddPolicy("OrderOwner", p => p.Requirements.Add(new OrderOwnerRequirement())) and services.AddScoped<IAuthorizationHandler, OrderOwnerHandler>(). Handlers are resolved from DI, so they can depend on repositories or other services just like anything else.Testing handlers in isolation
Because a handler is a plain class, you can unit test it directly against a constructed ClaimsPrincipal and a fake resource — no WebApplicationFactory, no HTTP pipeline, no real authentication needed.
[Fact]
public async Task NonOwnerNonAdmin_IsNotAuthorized()
{
var handler = new OrderOwnerHandler();
var user = TestPrincipal.WithClaims(("sub", "user-2"));
var order = new Order(Guid.NewGuid(), customerId: new CustomerId("user-1"));
var context = new AuthorizationHandlerContext(
new[] { new OrderOwnerRequirement() }, user, order);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
Picking the right level
| Approach | Use when |
|---|---|
| Role checks | Access depends only on a coarse role, no other context |
| Policy-based | Access depends on a combination of claims/roles, reusable across endpoints |
| Resource-based | Access depends on the specific object being acted on (ownership, status, tenant) |