Lesson 13 of 45 intermediate

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?

Open interactive version (quiz + challenge)

Real-world analogy

Think of an office building. Your badge opens the front door, and that proves you work here. Your badge only opens the floors your team needs. The rule is written in one place at the security desk. Even on your floor, you can open your own drawer but not a colleague's. Here the badge is the identity, floor access is a policy, and the drawer is resource-based authorization.

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

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. 1. AddPolicy names a rule: the user must carry the claim permission = orders.manage.
  2. 2. FallbackPolicy requires a signed-in user for any endpoint with no authorization setting of its own, so the default is closed.
  3. 3. The handler is registered in DI. The endpoint asks for IAuthorizationService so it can run resource checks in code.
  4. 4. The endpoint loads the order first. AuthorizeAsync then runs OwnerRequirement against that exact order.
  5. 5. If it fails the endpoint returns Forbid, a 403, because the user is known but not allowed.
  6. 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?
The role says who the caller is. What says whose order it is?
Show answer
Any signed-in Customer can read any order by changing the id (an IDOR bug). The role check does not compare the order owner with the caller. Fix: after loading the order, check order.OwnerId against the caller's id claim, or call IAuthorizationService with an ownership requirement, and return 403 on mismatch.

Explain like I'm 5

Your school badge gets you into the building. It only opens your own classroom, and it only opens your own locker. Each of those is a different question the school asks.

Fun fact

If no handler calls Succeed, the requirement fails. Several handlers for one requirement work like OR: one Succeed is enough. Only an explicit context.Fail() from any handler overrules that.

Hands-on challenge

Say it out loud in 30 seconds: "What is the difference between roles, claims and policies, and how do you stop one user reading another user's data?" Then check: did you mention 401 versus 403, a named policy, and an ownership check on the resource itself?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook