Authorization: Roles, Claims and Policies
Knowing who you are is not the same as being allowed in
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
A signed-in user without the right claim calls a protected endpoint. Which status is correct?
Real-world analogy
What is it?
Authorization decides whether a known user may perform an action. ASP.NET Core builds it from layers: simple attributes, role checks, claims-based policies, and custom handlers for resource rules. Policies give each rule a name and one home. Resource-based checks add the missing piece, which is whether this user may touch this specific record.
Real-world relevance
The most common authorization bug is IDOR, insecure direct object reference. The endpoint checks the user is a Customer, then loads order 42 by id. Any customer can change the number in the URL and read someone else's order. A role check alone does not stop this. The fix is to compare the order owner with the caller, ideally in one reusable handler.
Key points
- Authorize and AllowAnonymous — [Authorize] blocks callers who are not signed in. [AllowAnonymous] opens one endpoint on an otherwise protected controller, such as login.
- Role checks and their limit — [Authorize(Roles = "Admin")] is quick. It stops scaling when rules mix: managers of region X, or staff with a certain permission.
- Claims describe the user — A claim is a key and value on the user, such as permission = orders.manage. Policies can ask for claims instead of fixed role names.
- Policies name a rule once — Register a rule with AddPolicy and apply it by name. Changing the rule later means editing one place, not every controller.
- Requirements and handlers — For logic beyond a claim, write a requirement and a handler that calls context.Succeed. The handler can use services and read the database.
- Resource-based authorization — Attributes cannot know which order a request touches. Load the order, then call IAuthorizationService with it, so the rule can check ownership.
- 401 versus 403 — 401 means we do not know who you are. 403 means we know you and the answer is no. Returning the right one helps clients decide whether to sign in again.
- Fallback policy secures by default — Set FallbackPolicy to require an authenticated user. Endpoints with no rule of their own are then protected unless you mark them AllowAnonymous. A forgotten attribute then means signed-in only, not open to everyone.
Quick check
Answer from memory — scrolling up first makes it stick less.
Why can an [Authorize(Roles = "Customer")] attribute not stop customer A reading customer B's order?
What does setting a FallbackPolicy that requires an authenticated user do?
Code example
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageOrders",
policy => policy.RequireClaim("permission", "orders.manage"));
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build(); // secure by default
});
builder.Services.AddSingleton<IAuthorizationHandler, OrderOwnerHandler>();
app.MapPut("/orders/{id}", async (int id, ClaimsPrincipal user,
IAuthorizationService auth, AppDbContext db) =>
{
var order = await db.Orders.FindAsync(id);
if (order is null) return Results.NotFound();
var result = await auth.AuthorizeAsync(user, order, new OwnerRequirement());
if (!result.Succeeded) return Results.Forbid(); // 403: known user, not their order
order.Status = "Updated";
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization("CanManageOrders");
public class Order { public int Id { get; set; } public string OwnerId { get; set; } = ""; public string Status { get; set; } = ""; }
public class OwnerRequirement : IAuthorizationRequirement { }
public class OrderOwnerHandler : AuthorizationHandler<OwnerRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OwnerRequirement requirement, Order order)
{
if (order.OwnerId == context.User.FindFirstValue(ClaimTypes.NameIdentifier))
context.Succeed(requirement);
return Task.CompletedTask;
}
}Line-by-line walkthrough
- 1. AddPolicy names a rule: the user must carry the claim permission = orders.manage.
- 2. FallbackPolicy requires a signed-in user for any endpoint with no authorization setting of its own, so the default is closed.
- 3. The handler is registered in DI. The endpoint asks for IAuthorizationService so it can run resource checks in code.
- 4. The endpoint loads the order first. AuthorizeAsync then runs OwnerRequirement against that exact order.
- 5. If it fails the endpoint returns Forbid, a 403, because the user is known but not allowed.
- 6. OrderOwnerHandler compares order.OwnerId with the caller's id claim and calls Succeed only on a match.
Spot the bug
// Order has an OwnerId, and Orders is a DbSet<Order>
app.MapGet("/orders/{id}", async (int id, AppDbContext db) =>
{
var order = await db.Orders.FindAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
}).RequireAuthorization(p => p.RequireRole("Customer"));Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Introduction to authorization in ASP.NET Core (Microsoft Learn)
- Policy-based authorization (Microsoft Learn)
- Resource-based authorization (Microsoft Learn)
Lock it in