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.
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 |
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.
GET /api/orders/{id} with Authorization: Bearer <jwt>HttpContext.User from its claimsOrder and compares its CustomerId against the caller's sub claim, OR checks the Staff roleThe 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.
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.
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.
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.
[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.
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.
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."
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.
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.
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.