Resource-Based Authorization

Authorize against the loaded order or invoice with IAuthorizationService, enforce ownership, and choose between 404 and 403 deliberately.

Syntaxvar result = await authz.AuthorizeAsync(user, order, OrderOperations.Cancel); if (!result.Succeeded) return TypedResults.Forbid();

Policies in endpoint metadata run before the handler, so they can only look at the user and the request. Most real rules depend on the data: the order belongs to this customer, the invoice is still a draft, the refund is under the agent's limit. For those you load the resource first, then call IAuthorizationService.AuthorizeAsync(user, resource, requirement) (or a policy name) inside the handler, and turn the AuthorizationResult into a response yourself.

The moving parts

  • A handler derived from AuthorizationHandler<TRequirement, TResource> runs only when the resource passed in is a TResource.
  • OperationAuthorizationRequirement with a Name (Read, Update, Cancel) lets one handler cover every operation on a type; keep the instances as static fields.
  • TypedResults.Forbid() in a minimal API or Forbid() in a controller calls the authentication scheme's forbid, which writes 403. Guard the endpoint with RequireAuthorization() as well, so an anonymous caller gets 401 from the middleware before any data is loaded.

404 or 403

SituationReturnWhy
Caller may not see the resource at all (another customer's order)404A 403 confirms the id exists, which lets a caller enumerate orders
Caller can see it but may not do this (support agent cancelling)403Nothing new is revealed, and the client can explain the refusal
Caller may act, but the state forbids it (already shipped)409It is a business rule, not a permission

The hidden case must be indistinguishable from a missing row: same status, same body, no extra lookups that change the timing noticeably.

Why this matters

Broken object level authorization (an IDOR, insecure direct object reference) is the first item in the OWASP API Security Top 10: an authenticated user changes /orders/1042 to /orders/1043 and reads someone else's order. Endpoint policies cannot catch it, because every customer is allowed to call the endpoint. Only a check against the loaded resource can.

Lists are different

Never load a page of rows and call AuthorizeAsync per row: it is N calls, the paging breaks when rows are dropped afterwards, and a forgotten check leaks everything. Filter in the query instead (Where(o => o.CustomerId == customerId)), and keep the query predicate and the handler's rule next to each other so they cannot drift apart.

Production failure modes

  • Comparing the customer id in the route with the token instead of the loaded order's owner: /customers/17/orders/1043 passes while order 1043 belongs to customer 42.
  • Authorizing after the change is saved, or only on GET and not on PUT and DELETE.
  • Output caching or a CDN keyed only on the URL, serving one customer's order to the next caller.
  • Support tooling given a blanket role that bypasses ownership without an audit trail.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 404 when customer 42 requests /orders/1042, which belongs to customer 17, exactly as for an order that does not exist
  • Let support agents read any order but return 403 when they try to cancel one on the customer's behalf
  • Allow an account manager to void invoices only for customers they manage, and only up to 10,000.00 EUR without a finance lead

More examples

A controller with an async, data-dependent handler

Here 403 is the right answer: every account manager may see invoices, so refusing one they do not manage reveals nothing. The rule mixes a role, an amount threshold and a relationship that needs a query, which is exactly what an endpoint policy cannot express because it runs before the invoice is loaded. Register <code>VoidInvoiceHandler</code> as scoped because <code>IAccountManagers</code> uses the database, and call <code>AddHttpContextAccessor()</code>: the resource here is the invoice, so <code>context.Resource</code> cannot supply <code>RequestAborted</code> and the accessor does, letting an aborted request cancel the query.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Shop.Api.Invoices;

public sealed record Invoice(Guid Id, string Number, string CustomerId, decimal Amount, bool IsDraft);

public interface IInvoiceRepository
{
    Task<Invoice?> FindAsync(Guid id, CancellationToken cancellationToken);
}

public interface IAccountManagers
{
    Task<bool> ManagesAsync(string staffId, string customerId, CancellationToken cancellationToken);
}

public sealed class VoidInvoiceRequirement : IAuthorizationRequirement;

public sealed class VoidInvoiceHandler(IAccountManagers managers, IHttpContextAccessor accessor)
    : AuthorizationHandler<VoidInvoiceRequirement, Invoice>
{
    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context, VoidInvoiceRequirement requirement, Invoice invoice)
    {
        var staffId = context.User.FindFirstValue("sub");
        if (staffId is null || !context.User.IsInRole("account-manager")) return;
        if (invoice.Amount > 10_000m && !context.User.IsInRole("finance-lead")) return;
        // The resource is the invoice, not the HttpContext, so the request's token comes from the accessor.
        var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
        if (await managers.ManagesAsync(staffId, invoice.CustomerId, cancellationToken))
            context.Succeed(requirement);
    }
}

[ApiController]
[Authorize(Roles = "account-manager")]
[Route("api/invoices")]
public sealed class InvoicesController(IInvoiceRepository invoices, IAuthorizationService authz) : ControllerBase
{
    private static readonly VoidInvoiceRequirement Void = new();

    [HttpPost("{id:guid}/void")]
    public async Task<IActionResult> VoidInvoice(Guid id, CancellationToken cancellationToken)
    {
        var invoice = await invoices.FindAsync(id, cancellationToken);
        if (invoice is null) return NotFound();

        var result = await authz.AuthorizeAsync(User, invoice, Void);
        if (!result.Succeeded) return Forbid();

        return invoice.IsDraft ? NoContent() : Conflict("Only draft invoices can be voided.");
    }
}

Filter lists in the query, not per row

Loading twenty rows and authorizing each one afterwards returns short or empty pages and costs twenty handler calls; worse, one forgotten check leaks the whole table. Putting the ownership predicate in the query lets the database return only rows the caller may see, so paging and counts stay correct. When the read rule changes, change this predicate and the handler together, ideally with a test that compares them.

Example · csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlite("Data Source=shop.db"));
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorization();

var app = builder.Build();

app.MapGet("/orders", async (ClaimsPrincipal user, ShopDb db, int page = 1) =>
{
    var customerId = user.FindFirstValue("sub");
    var orders = await db.Orders.AsNoTracking()
        // The same ownership rule as the Read handler, applied by the database.
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.PlacedAt)
        .Skip((page - 1) * 20).Take(20)
        .Select(o => new OrderSummary(o.Id, o.Total, o.Status))
        .ToListAsync();
    return TypedResults.Ok(orders);
}).RequireAuthorization();

app.Run();

public sealed record OrderSummary(int Id, decimal Total, string Status);

public sealed class Order
{
    public int Id { get; set; }
    public required string CustomerId { get; set; }
    public decimal Total { get; set; }
    public required string Status { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
}

public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

Pitfall: trusting the route instead of the resource

Nested routes make it tempting to check only the parent id: the caller really is customer 17, so the check passes, and order 1043 is returned even though it belongs to customer 42. Every object-level check must compare the principal with the owner stored on the loaded resource. In a database query that means filtering on both ids, <code>o.Id == orderId &amp;&amp; o.CustomerId == customerId</code>.

Example · csharp
using System.Collections.Concurrent;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddSingleton<ConcurrentDictionary<int, Order>>();

var app = builder.Build();

app.MapGet("/customers/{customerId}/orders/{orderId:int}", (
    string customerId, int orderId, ClaimsPrincipal user, ConcurrentDictionary<int, Order> orders) =>
{
    // Wrong on its own: it proves the caller is customerId, not that the order is theirs.
    if (user.FindFirstValue("sub") != customerId) return Results.NotFound();

    // Right: compare against the owner of the row that was actually loaded.
    if (!orders.TryGetValue(orderId, out var order) || order.CustomerId != customerId)
        return Results.NotFound();

    return Results.Ok(order);
}).RequireAuthorization();

app.Run();

public sealed record Order(int Id, string CustomerId, decimal Total);

Discussion

  • Be the first to comment on this lesson.