
Quick Answer
In ASP.NET Core, authorization belongs in IAuthorizationRequirement and AuthorizationHandler classes behind a named policy, evaluated by the framework as a request moves through the pipeline. It does not belong inside your domain entities or endpoint handlers. Keep it in the authorization middleware and you get one source of truth, checks you can unit test without logging in, a domain model that stays clean, and unauthorized requests that get turned away before any code in your endpoint handlers allocates objects or calls databases.
Where does authorization logic usually end up?
No one sets out to scatter authorization across a codebase. It happens one reasonable decision at a time. A new endpoint needs locking down, so someone adds a role check right where the work happens. Later an entity has to refuse an operation, so a check goes there too. Every one of those was the fast, obvious path to done. Add them up over a couple of years and the answer to "who is allowed to cancel an order" lives in four places, and no two of them quite agree.
Two spots collect most of it. The first is the domain entity, where a method like order.Cancel(user) grows an if about roles and owners. The second is the endpoint handler, where the same if gets pasted in right before the real work. Both feel natural in the moment. Both cost you later.
Why shouldn't authorization live in domain entities?
A domain entity's job is to protect its own state and enforce the rules of the business. Who is asking is not one of those rules. The moment an Order needs a ClaimsPrincipal to decide whether it can be cancelled, three things go wrong.
First, your domain now depends on System.Security.Claims and, through it, on the whole identity story of the web layer. The dependency arrow points the wrong way. The layer that is supposed to know nothing about HTTP now knows about the current user.
Second, you cannot construct the entity in a test without building a fake user first. A rule as simple as "a shipped order cannot be cancelled" now drags authorization into a test that has nothing to do with authorization.
Third, the same actor check gets re-implemented on every entity that needs it. Order grows one. Invoice grows a slightly different one. They drift, quietly, because nothing forces them to agree.
Why shouldn't authorization live in endpoint handlers?
Putting the check in the handler feels better, because at least the domain stays clean. It has its own problems.
The check gets duplicated across every endpoint that touches the resource. Cancel an order in one place, refund it in another, export it in a third, and that same block about owners and admins gets pasted into all three. When the rule changes, and it will, you are hunting for every copy.
Nothing forces a new endpoint to include the check at all. Six months from now someone adds a PATCH /orders/{id} in a hurry and forgets the four lines. There is no compiler error. There is no failing test, because the check was never something you could test on its own. There is just a hole, and finding that out is usually embarrassing.
By the time your handler is executing, the request has already been routed and model-bound, and usually you have already gone to the database to load the very thing you are about to deny access to. More on that in a moment.
What do you gain by putting authorization in the authorization layer?
- One source of truth. The rule lives in a single handler, referenced everywhere by policy name. Change it once and every endpoint and use case that names the policy changes with it.
- You can test it without logging in. A handler is a plain class. You hand it a
ClaimsPrincipaland a resource, call it, and assert on the result. No HTTP, no browser, no clicking through the app as four different people every release. That manual tax is its own problem, and I wrote about it in We Spent Four Hours a Release. - The dependency arrow points the right way. The domain stays a domain. Identity concerns sit in the authorization layer where they belong, and your entities go back to being about the business.
- Intent is declarative and discoverable.
[Authorize("CancelOrder")]or.RequireAuthorization("CancelOrder")reads as a statement of intent. Someone reviewing the code, or auditing who can do what, can enumerate the policies instead of grepping for scatteredifstatements. - It composes. A policy can carry more than one requirement, and resource-based checks run through
AuthorizeAsync(user, resource, policy)when the decision needs the loaded object. You build decisions out of small named pieces instead of one tangled condition. - Failures are consistent. An authorization failure returns a plain
403. A business rule violation returns something specific like a422. Keeping the two apart is easier when they live in different layers, and knowing which is which comes down to one test, covered in Does the User Change the Outcome?. - It fails fast, before any work. Unauthorized requests get turned away in the pipeline, before your handler allocates anything or opens a connection. The next section digs into that.
Does putting authorization in a policy improve performance?
Yes, for the checks that run in the pipeline. UseAuthorization sits in the middleware pipeline ahead of the endpoint. When a request fails a policy applied with [Authorize] or RequireAuthorization, ASP.NET Core short-circuits to a 403 before your handler is ever invoked. Nothing in the handler runs. No objects get allocated. No database connection gets opened. No query goes out.
Compare that to the same check sitting inside the handler:
app.MapPost("/orders/{id}/cancel", async (int id, ClaimsPrincipal user, AppDb db) =>
{
var order = await db.Orders.FindAsync(id); // the query already ran
if (order is null) return Results.NotFound();
if (!user.IsInRole("Admin") && user.GetUserId() != order.OwnerId)
return Results.Forbid(); // too late to save the work above
order.Cancel();
await db.SaveChangesAsync();
return Results.NoContent();
});
By the time the authorization check says no, you have already paid for the load. Put the same rule in a domain entity and it is even later, because now you have constructed objects and called into them before finding out the caller was never allowed in.
There is a real nuance here. The early exit that occurs in the authorization middleware only applies to checks that can be answered from the ClaimsPrincipal alone, meaning role and claim based policies. A resource-based check needs the resource loaded first, so it cannot save the query that loads it. That means that policies relying on ClaimsPrincipal data can be used to filter out requests, even if a resource-based check needs to be performed. There will still be a performance gain when the user lacks the needed claims to perform the action.
Decision table: entity, endpoint, or policy?
| The check... | Belongs in |
|---|---|
| Depends on who is asking, answerable from claims alone | A policy, applied in the pipeline ([Authorize]) |
| Depends on who is asking and on the specific resource instance | A resource-based AuthorizationHandler<T, TResource> |
| Depends only on the resource's own state, not on who is asking | The domain entity |
| Explains a specific business failure the caller is allowed to attempt | The use case or handler, returning a specific error |
Worked example: cancelling an order
The requirement: "Only the order's owner or an admin can cancel it. A shipped order cannot be cancelled by anyone."
There are two rules in that sentance. The first is authorization, because swapping the user changes the answer. The second is business logic, because the order's status decides it no matter who asks.
Here is the version that buries both in the entity. Notice the entity now takes a ClaimsPrincipal.
public class Order
{
public string OwnerId { get; private set; }
public OrderStatus Status { get; private set; }
// Now the domain depends on identity, and you cannot test the
// shipped-order rule without first building a fake user.
public void Cancel(ClaimsPrincipal user)
{
if (!user.IsInRole("Admin") && user.GetUserId() != OwnerId)
throw new UnauthorizedAccessException();
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("Shipped orders cannot be cancelled.");
Status = OrderStatus.Cancelled;
}
}
Now split the two rules apart. The actor gate moves into an authorization handler. The status rule stays in the entity, where it belongs, with no user in sight.
public class CancelOrderRequirement : IAuthorizationRequirement { }
public class CancelOrderHandler : AuthorizationHandler<CancelOrderRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CancelOrderRequirement requirement,
Order order)
{
if (context.User.IsInRole("Admin") || context.User.GetUserId() == order.OwnerId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class Order
{
public string OwnerId { get; private set; }
public OrderStatus Status { get; private set; }
// Pure business rule. No user, no claims, no HTTP.
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new InvalidOperationException("Shipped orders cannot be cancelled.");
Status = OrderStatus.Cancelled;
}
}
The endpoint asks the authorization service the ownership question, then lets the entity enforce its own rule.
app.MapPost("/orders/{id}/cancel", async (
int id, ClaimsPrincipal user, AppDb db, IAuthorizationService auth) =>
{
var order = await db.Orders.FindAsync(id);
if (order is null) return Results.NotFound();
var result = await auth.AuthorizeAsync(user, order, "CancelOrder");
if (!result.Succeeded) return Results.Forbid();
order.Cancel();
await db.SaveChangesAsync();
return Results.NoContent();
});
The payoff shows up in the tests. The authorization rule is now a plain object you can check in a few lines, with no server and no login.
[Fact]
public void Owner_can_cancel_their_own_order()
{
var order = new Order(ownerId: "user-123");
var user = FakeUser("user-123"); // no admin role
var handler = new CancelOrderHandler();
var requirement = new CancelOrderRequirement();
var context = new AuthorizationHandlerContext([requirement], user, order);
handler.HandleRequirementAsync(context, requirement, order).Wait();
Assert.True(context.HasSucceeded);
}
The shipped-order rule gets its own test that never mentions a user, because it never needed one.
What if the rule is really domain logic?
Not everything that looks like a permission is one. The test is simple. Swap the current user for a different user and hold everything else steady. If the answer changes, it is authorization and it belongs in a policy. If the answer stays the same, it is business logic and it belongs in the entity or the use case. "A shipped order cannot be cancelled" does not care who is asking, so it stays in the Order. I go deeper on that dividing line in Does the User Change the Outcome?.
The point is not to drain all logic out of your entities. It is to stop mixing the two questions in the same method, because that mix is what makes both of them hard to change and hard to test.
Where AuthorizationHub fits
Most authorization needs are based on a group or role membership. AuthorizationHub makes it easy for you to set who is in a group or role without deploying code. It comes with an user interface to manage users, groups, roles, and tenants. It also ships with prebuilt requirements that you can use in policies. Org Tree Claims and ASP.NET Core Policies walks through how that works.
FAQ
Should a domain entity ever take a ClaimsPrincipal or a user?
As a rule, no. If an entity method needs to know who is asking, that is a sign an authorization decision has leaked into the domain. Move the actor check into a policy and let the entity enforce only rules about its own state.
Isn't [Authorize(Roles = "Admin")] enough on its own?
For coarse role gates, sometimes. It stops being enough the moment the decision depends on the specific resource, such as "the owner of this order." That is what resource-based handlers and AuthorizeAsync(user, resource, policy) are for.
What about checks that genuinely need the resource loaded? Those are resource-based authorization, and they run after the load, so they will not save you that query. Keep them, but push everything that does not need the resource into a pipeline policy so the clearly-not-allowed requests get rejected before you go to the database.
Doesn't a policy just move the duplication somewhere else?
No. The duplicated inline if becomes one handler referenced by name. The endpoints say RequireAuthorization("CancelOrder") instead of each carrying its own copy of the rule. One place to change, one place to test.
Does an unauthorized request still hit the database?
Not if the deciding check is a pipeline policy answerable from claims. It is rejected with a 403 before your handler runs. It does hit the database if the check lives in the handler or the entity, because by then the load has already happened.