
Authorization vs. Business Logic in C#: How to Know Where Each One Belongs
Quick answer: A check belongs in an authorization policy when the identity of the user determines the outcome. If the answer would be the same no matter which user is asking, it's business logic, not authorization — even if it looks like a permission check. In ASP.NET Core, authorization checks belong in IAuthorizationRequirement / AuthorizationHandler classes; business logic belongs in the controller, use case, or domain model.
What is the difference between authorization and business logic?
Authorization answers one specific question: is this actor allowed to perform this action on this resource? Business logic answers a different question: given that the action is allowed, what should the system do — and does the request itself make sense?
Authorization is a gate. Business logic is the workflow that runs once you're through the gate. They both often show up as if statements in code, which is why they get confused, but they're answering fundamentally different questions.
How do you tell if a piece of logic is authorization or business logic?
Swap the current user for a different user, holding everything else the same. Does the output change?
- If yes, the logic is authorization. Put it in a policy.
- If no, the logic is business logic. Put it in the controller, use case, or domain model — not a policy.
This single test resolves the vast majority of cases, including ones that look deceptively like permission checks. For example: "a discount code can only be applied to orders under $500" looks like a yes/no gate, but the answer doesn't depend on who's asking — it depends on the order total. Because swapping the user doesn't change the outcome, it's a business rule, not authorization, regardless of how it's phrased in the requirements.
What's the difference between role-based and resource-based authorization in ASP.NET Core?
Once you know something is authorization, a second question tells you which kind:
Holding the user constant, does swapping the specific resource instance change the answer?
- No → role/claim-based authorization. Answerable from the
ClaimsPrincipalalone, with no database lookup. Implemented asAuthorizationHandler<TRequirement>. - Yes → resource-based authorization. Requires loading the actual resource and comparing an attribute — ownership, tenant, status — against the user. Implemented as
AuthorizationHandler<TRequirement, TResource>.
For example, "any Accountant can edit any invoice" is role-based — the specific invoice doesn't matter. "This Accountant can edit this invoice because they own it" is resource-based — the specific invoice does matter.
Decision table: authorization policy vs. controller/use case
| Question | Points to authorization policy | Points to controller/use case |
|---|---|---|
| Does the answer change if you swap the current user? | Yes | No — driven by resource or request state instead |
| What should a failed check look like? | Generic 403 Forbidden | Specific 400/422 with an explanation |
| Does answering it require loading a resource and comparing it to the user? | Yes (resource-based policy) | Yes, but compared only to the resource's own state, not the user (business rule) |
Why should authorization failures return a generic 403 instead of a specific error message?
Authorization failures conventionally return a plain 403 Forbidden with no detail, because explaining why someone was denied can leak information about a resource they aren't supposed to know exists — its state, its owner, whether it exists at all. Business rule violations, by contrast, are expected to explain themselves (422 Unprocessable Entity with "orders over $500 aren't eligible for this code"), because the user is authorized to attempt the action; it's just invalid given the current state.
If you find yourself wanting to give a detailed, specific explanation for a denial, that's a signal you're looking at a business rule, not an authorization check.
Common mistakes when splitting authorization from business logic in C#
- Fat authorization handlers. An
AuthorizationHandlerthat calculates order totals or checks inventory counts has taken on business logic. Policies should compare existing state to the actor, not compute new state. - Duplicated inline checks. The same
User.IsInRole("Admin") || User.GetUserId() == resource.OwnerIdcopy-pasted across multiple controllers should be extracted into a singleAuthorizationHandlerreferenced by policy name. - Policies that reach into the request for the decision. Under endpoint routing,
context.Resourceis set to theHttpContextwhen a policy is invoked via[Authorize], so handlers often have it in hand — that alone isn't a problem. The smell is a handler casting it to pull route values, query string, or body fields into the decision, or injectingIHttpContextAccessorto do the same. If the outcome depends on what's in the request rather than on the actor and the resource, that logic belongs in the controller or use case. Prefer callingAuthorizeAsync(user, resource, policy)so the handler receives a typed resource instead of a request. - Classifying by surface form instead of by the actor test. A method named
CanEditOrder()that returnsboolis not automatically authorization — apply the "does the user change the outcome" test regardless of naming.
Worked example: archiving a project
Requirement: "Only project owners and admins can archive a project. A project can't be archived if it has open tasks assigned to other people, unless the archiving user is an admin."
This single requirement splits into two layers:
// Authorization: actor + resource, fails with 403
public class ArchiveProjectRequirement : IAuthorizationRequirement { }
public class ArchiveProjectHandler : AuthorizationHandler<ArchiveProjectRequirement, Project>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ArchiveProjectRequirement requirement,
Project project)
{
if (context.User.IsInRole("Admin") || project.OwnerId == context.User.GetUserId())
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Business logic: state-dependent, fails with a specific error
public class ArchiveProjectCommandHandler
{
private readonly IAuthorizationService _authorizationService;
public async Task Handle(ArchiveProjectCommand command)
{
var result = await _authorizationService.AuthorizeAsync(
command.User, command.Project, "ArchiveProjectPolicy");
if (!result.Succeeded)
throw new UnauthorizedAccessException();
if (command.Project.HasOpenTasksAssignedToOthers(command.User) && !command.User.IsInRole("Admin"))
throw new DomainException("Cannot archive: open tasks assigned to others");
command.Project.Archive();
}
}
The archive permission is resource-based authorization: both the user and the specific project matter. The open-tasks rule is business logic: swap the user and the answer to "does this project have open tasks assigned to others" doesn't change — it's the project's state doing the work, not the identity of the person asking. The small admin override is fine to keep here, since it references the already-computed authorization result rather than re-deriving actor logic.
FAQ
Is authorization always about roles? No. Role-based authorization (any Accountant can edit any invoice) is one kind. Resource-based authorization (this Accountant can edit this invoice because they own it) is another, and requires comparing a specific resource instance against the user rather than just reading claims.
Can a single business rule reference an authorization result? Yes. A business rule can check "is this user an admin" as part of a larger state-dependent condition without itself becoming an authorization check — as long as the underlying permission was already evaluated by a policy rather than re-implemented inline.
Should validation logic ever live in an authorization policy?
No. Validation of request data, order totals, or workflow state is business logic. If a check doesn't vary by actor, it doesn't belong in an AuthorizationHandler, regardless of how gate-like it looks.
What's the simplest test to remember? Swap the user. If the answer changes, it's authorization. If it doesn't, it's business logic.