Authorization Policies in a Real ASP.NET Core API
260 through 263 taught how a token proves who's asking. This lesson decides, endpoint by endpoint, what OrderFlow actually lets them do about it.
You already know how to issue and verify a JWT (262), how to register a bearer scheme (260), and how to write a policy that checks more than one claim at once (261). What none of those lessons could show you is the actual list of rules a real API has to enforce — because that list is specific to the application, not to the mechanism. OrderFlow's rules are simple to state and easy to get subtly wrong to implement: a customer can place and view their own orders, and only their own; staff can view every order in the system; the product catalog is public.
This lesson doesn't re-explain JWTs, claims, or how policies are built — go back to 260, 261, 262, and 263 if any of that mechanism feels unfamiliar. What follows is OrderFlow's actual token shape, its actual policies, and the actual endpoint-by-endpoint rules those policies enforce — including the one mistake that quietly defeats all of it if you get it wrong.
What Is It?
OrderFlow authenticates every non-public request with a JWT bearer token, issued at login and carrying two claims that matter for every authorization decision downstream:
// The claims OrderFlow puts inside every issued JWT
new Claim(JwtRegisteredClaimNames.Sub, customer.Id.ToString()), // WHO — the authenticated Customer's own Id
new Claim(ClaimTypes.Role, isStaff ? "Staff" : "Customer") // WHAT KIND — drives every policy belowEvery OrderFlow endpoint falls into exactly one of three access rules, and getting the mapping right is this lesson's entire job:
| Endpoint | Access rule |
|---|---|
| GET /api/products | Public — no authentication required at all |
| POST /api/orders | Authenticated — the order is always placed for the caller's own CustomerId, taken from the token, never from the request body |
| GET /api/orders/{id} | The order's owner, OR any Staff member — resource-based, per 261 |
| GET /api/orders | Staff only — every other customer's order history is off-limits to a customer |
Why Does It Exist?
OrderFlow's real risk isn't "can a stranger see the product catalog" — that's meant to be public. The real risk is one customer reading, or worse placing, an order under another customer's identity, or a customer reaching an endpoint meant only for staff. 261 already taught that role checks alone ("is this user a Customer") aren't enough here — GET /api/orders/{id} needs to know not just that the caller is a customer, but which customer, and whether the order being requested actually belongs to them. That's exactly the resource-based, multi-condition case 261's "OwnsTicketOrIsSupportStaff" example previewed — this lesson is where OrderFlow builds its own version of it for real.
Big Picture — Where This Sits in the Request Pipeline
GET /api/orders/{id} with Authorization: Bearer <jwt>↓
Authentication (260/262) — verifies the JWT's signature and expiration, builds
HttpContext.User from its claims↓
Authorization (261) — the "OwnsOrderOrIsStaff" policy loads the requested
Order and compares its CustomerId against the caller's sub claim, OR checks the Staff role↓
Denied → 403 Forbidden (the caller was authenticated — they're just not allowed here)
Allowed → OrdersController calls OrderService (334) exactly as if authorization never happened
How It Works — Building OrderFlow's Policies
- AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...) in OrderFlow.Api, validating issuer, audience, signing key, and lifetime exactly as 262 specified — nothing new here.
- options.AddPolicy("StaffOnly", p => p.RequireRole("Staff")) — a plain role check, exactly the simple case 261 opened with.
- A plain claim check can't express "matches the specific order being requested" — that needs an IAuthorizationHandler that receives the Order itself as the resource, exactly the mechanism 261's "Under the Hood" section named but didn't build out.
- Unlike a plain [Authorize(Policy = "...")] attribute, a resource-based check needs the resource loaded first — so OrdersController fetches the Order, then explicitly calls IAuthorizationService.AuthorizeAsync(User, order, "OwnsOrderOrIsStaff") before returning it.
Simple Example — Placing an Order Only for Yourself
The single most important line of code in this entire lesson is the one that decides whose order gets placed — and it's a deletion, not an addition:
[Authorize]
[HttpPost("orders")]
public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request, CancellationToken ct)
{
// The CustomerId comes from the VERIFIED TOKEN, never from the request body.
var customerId = Guid.Parse(User.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
var orderId = await orderService.PlaceOrderAsync(customerId, request.Items, ct);
return CreatedAtAction(nameof(GetOrder), new { id = orderId }, null);
}
// PlaceOrderRequest deliberately has NO CustomerId property at all —
// there is nothing in the request body for a malicious caller to override.
public record PlaceOrderRequest(List<OrderItemRequest> Items);Meaning: If PlaceOrderRequest had a CustomerId field that the controller trusted, any authenticated customer could place an order under someone else's account just by editing the JSON body — the token would prove who they are, and the endpoint would ignore that proof entirely. Deriving customerId from the verified claim, and never accepting it as client input, is what actually closes that gap.
Real-World Example — the Resource-Based Handler for GetOrder
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("StaffOnly", p => p.RequireRole("Staff"));
options.AddPolicy("OwnsOrderOrIsStaff", p => p.Requirements.Add(new OwnsOrderRequirement()));
});
builder.Services.AddScoped<IAuthorizationHandler, OwnsOrderAuthorizationHandler>();
public class OwnsOrderRequirement : IAuthorizationRequirement { }
public class OwnsOrderAuthorizationHandler : AuthorizationHandler<OwnsOrderRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OwnsOrderRequirement requirement, Order order)
{
var isStaff = context.User.IsInRole("Staff");
var isOwner = context.User.FindFirstValue(JwtRegisteredClaimNames.Sub) == order.CustomerId.ToString();
if (isStaff || isOwner)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// In OrdersController:
[Authorize]
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var order = await orderService.GetByIdAsync(id, ct);
if (order is null) return NotFound();
var result = await authorizationService.AuthorizeAsync(User, order, "OwnsOrderOrIsStaff");
if (!result.Succeeded) return Forbid(); // 403 — authenticated, just not permitted
return Ok(order);
}This is 261's IAuthorizationHandler mechanism, resolved against OrderFlow's actual Order entity instead of an illustrative ticket — the requested order's CustomerId is compared against the caller's own sub claim, and a Staff role short-circuits that check entirely.
Analogy
A staff badge (the JWT) proves who's carrying it the moment it's scanned — that's authentication. But a shared mailroom doesn't just check "is this a valid employee badge" at its door; it checks whether this specific employee's mailbox is the one being opened, or whether the person is a mailroom staff member allowed to open any of them. The badge alone answers "who." Whether that specific "who" is allowed at that specific mailbox is a second, separate check — exactly what OwnsOrderAuthorizationHandler does against a specific Order instead of a generic role.
Under the Hood — Why This Can't Be a Plain [Authorize(Policy=...)] Attribute
An attribute like [Authorize(Policy = "StaffOnly")] runs before the action method's body — it never sees the specific Order the request is about, because that data hasn't been loaded yet. 261's AuthorizationHandlerContext can carry an optional resource object precisely for cases like this, but only when something explicitly hands it one — which is why GetOrder loads the order first, then calls IAuthorizationService.AuthorizeAsync(User, order, policyName) by hand instead of relying on the attribute alone. This is a genuine, structural limit of attribute-based authorization, not a workaround for a missing feature — any check that depends on the specific resource being requested needs this explicit, two-step shape.
Common Confusion
1. "[Authorize] on PlaceOrder is enough on its own" — it only proves someone is logged in, not who they're acting as
[Authorize] alone guarantees the request carries a valid, unexpired token — that's authentication doing its job (260). It says nothing about which fields in the request body can be trusted. The actual safety in the Simple Example comes from where customerId is read from — the verified claim — not from the presence of [Authorize] by itself.
2. "401 and 403 are interchangeable here" — they mean two different, specific things
An anonymous request to GET /api/orders/{id} with no token at all gets a 401 — authentication never succeeded, exactly as 260 described a challenge working. A request from a real, authenticated customer who simply isn't that order's owner gets a 403 from Forbid() — authentication succeeded, authorization refused. Returning 401 for the second case would incorrectly suggest the customer's login itself is invalid, when it's their access to this specific resource that's the issue.
Common Mistakes
Mistake 1 — Trusting a client-supplied CustomerId anywhere in the request
Adding a CustomerId property to PlaceOrderRequest "to make the API more flexible," then using it instead of the token's sub claim. Never accept an identity field from the request body when that identity is already available, verified, from the authentication layer — the token is the only trustworthy source for "who is this."
Mistake 2 — Checking ownership with a plain policy instead of a resource-based handler
Trying to express "owns this order" as a static RequireClaim policy, which can only compare a claim against a fixed, known-in-advance value — it has no way to compare against whichever order id happens to be in the URL for this specific request. Use a resource-based IAuthorizationHandler, exactly as built above, whenever the check depends on data only known once the specific resource is loaded.
Mistake 3 — Forgetting the resource-based check entirely on a "just get by id" endpoint
Writing GetOrder with only [Authorize] and no follow-up call to AuthorizeAsync, reasoning "they're logged in, that's enough" — this lets any authenticated customer read any other customer's order by guessing or enumerating ids. Any endpoint that returns data scoped to one customer needs an explicit ownership check, not just proof that the caller is logged in as someone.
When Should I Use It?
- Reach for a plain role policy ("StaffOnly") whenever the check doesn't depend on which specific resource is being accessed — it's simpler, and it's all 261's "RequireRole" case needs.
- Reach for a resource-based handler the moment "is this allowed" depends on comparing the caller's identity against a specific record — order ownership, and any future OrderFlow feature shaped like it.
- Never accept an identity field (a customer id, a user id) as request input when it's already available from the verified token — this holds regardless of which endpoint you're writing.
Mental Model
Authenticated, self only = POST /api/orders — CustomerId always comes from the token's sub claim.
Owner or Staff = GET /api/orders/{id} — a resource-based IAuthorizationHandler, checked against the loaded Order.
Staff only = GET /api/orders — a plain RequireRole("Staff") policy.
Remember: the token proves who's asking; the policy decides what they're allowed to touch — and the second one sometimes needs the specific data loaded first.
Key Takeaway
- OrderFlow's JWT carries a sub claim (the Customer's own id) and a role claim (Customer or Staff) — every authorization decision in the API is built from those two facts.
- Placing an order always derives CustomerId from the verified token, never from request input — the single most important safeguard in this lesson.
- Viewing a specific order needs a resource-based IAuthorizationHandler (261's mechanism, built out for real) that compares the loaded Order's CustomerId against the caller, or checks the Staff role — a plain attribute-based policy can't express this on its own.
- Viewing every order in the system is Staff-only, enforced with a plain role policy — no resource-based check needed, since it doesn't depend on any specific order.
- This lesson's endpoints and policies are exactly what 338's background pipeline and 339's messaging build on top of — OrderService.PlaceOrderAsync is called with an already-verified CustomerId, never a re-derived or re-trusted one.
Check Your Understanding
You've seen OrderFlow's actual access rules and the two shapes of policy that enforce them. Let's confirm the reasoning behind each one.
1. Why does PlaceOrderRequest deliberately have no CustomerId property at all?
Show answer
Correct: B
Why B is correct: This is the Simple Example's entire point — the identity of who's placing the order is already known, verified, from the token. Accepting it again from the request body would let a malicious caller override it.
Why A is incorrect: Guid serializes to JSON without any issue — this isn't a technical serialization limitation.
Why C is incorrect: The lesson's identity source is the sub claim carrying the Customer's id, not an email lookup.
Why D is incorrect: The lesson doesn't describe any staff override for placing orders on a customer's behalf — the rule is uniform: identity always comes from the token.
Reinforcement: Never accept an identity field as request input when it's already available, verified, from authentication.
2. A customer who is authenticated but does not own the requested order calls GET /api/orders/{id}. What should happen, and why?
Show answer
Correct: B
Why B is correct: This is exactly Common Confusion #2 — authentication succeeded (there's a valid token), but authorization refused, which is precisely what a 403 from Forbid() communicates.
Why A is incorrect: 401 would incorrectly imply the token itself failed verification — it didn't; the caller is genuinely, validly authenticated as themselves.
Why C is incorrect: This is precisely what OwnsOrderAuthorizationHandler exists to prevent — a customer is scoped to their own orders unless they're Staff.
Why D is incorrect: The lesson's design returns 403 for this case, not a concealing 404 — while hiding existence is a legitimate pattern in some systems, it isn't what this lesson's GetOrder implementation does.
Reinforcement: 401 means "I don't know who you are"; 403 means "I know exactly who you are, and the answer is no."
3. Why can't OrderFlow's "owns this order, or is Staff" rule be expressed as a plain [Authorize(Policy = "...")] attribute with a RequireClaim-style policy alone?
Show answer
Correct: B
Why B is correct: The Under the Hood section explains this precisely — a plain policy attribute runs before the specific resource is loaded, so it has no way to compare the caller against data (like a specific order's owner) that only becomes known once that resource is fetched. That's exactly why GetOrder loads the order first and calls AuthorizeAsync explicitly.
Why A is incorrect: RequireClaim can compare against any claim value, Guid-derived strings included — the limitation is about timing (when the resource is known), not data type.
Why C is incorrect: Role-based and policy-based authorization compose freely, as "StaffOnly"'s RequireRole policy in this same lesson demonstrates.
Why D is incorrect: The Real-World Example builds exactly such a custom handler (OwnsOrderAuthorizationHandler) — the framework fully supports this.
Reinforcement: Any authorization check that depends on a specific resource's data needs that resource loaded first — which is precisely the structural gap resource-based handlers close.
OrderFlow now knows exactly who's allowed to do what. Next: 336 gives the schema behind Order, Customer, and Product a real, efficient shape — applying the Repository Pattern, query optimization, and connection management to OrderFlow's actual data access.