Authorization
By the end of this lesson
Control access with roles, claims and policies.
Authentication told you who is calling. Authorization decides what they are allowed to do. It is the step that turns an identity into an answer of yes or no for a specific operation.
ASP.NET Core gives you three ways to express the rule, and they are not alternatives so much as increasing levels of precision. Roles are coarse labels. Claims are individual facts about the caller. Policies are named rules built from either, evaluated by code you write.
There is also a fourth case that none of the three can express on its own. A line manager may edit the records of employees in their own team. The same person, calling the same endpoint, is allowed for one employee and not for another.
No role expresses that, because the answer depends on the record. No claim expresses it either, unless you are prepared to put every employee id the manager owns into the token. The decision needs the employee loaded, so it has to happen inside the request. That is what resource-based authorization is for, and it is where most real authorization bugs live.
The vocabulary, because these words are used loosely and the distinctions matter:
- Role
- A named group the caller belongs to, such as HrAdministrator. Simple, and it answers only membership questions. Roles arrive as claims in the token like everything else.
- Claim
- One statement about the caller: their department, their employment type, whether they have completed a required training module. Finer-grained than a role and often more truthful about why access is granted.
- Policy
- A named rule registered once and applied by name. It can combine roles, claims and arbitrary logic, so the rule lives in one place instead of being spelled out at every endpoint.
- Requirement
- A small object describing one condition a policy needs, such as "is a line manager of at least five years' service". It carries data, not logic.
- Handler
- The code that evaluates a requirement and either succeeds it or leaves it unmet. A requirement can have several handlers, and succeeding in any one of them is enough.
Roles and policies solve overlapping problems with different consequences:
| Role check | Policy | |
|---|---|---|
| Written as | [Authorize(Roles = "HrAdministrator")] | [Authorize(Policy = "CanEditSalary")] |
| Where the rule lives | Repeated at every endpoint that needs it | Registered once in one place |
| Changing the rule | Find and edit every attribute | Edit the policy; endpoints are untouched |
| What the endpoint tells a reader | Which group may call it | Which capability it requires, which is usually the more meaningful statement |
| Can express "this record" | No | Yes, with resource-based authorization |
builder.Services.AddAuthorizationBuilder()
// A role, named once so endpoints do not repeat the string
.AddPolicy("HrStaff", policy =>
policy.RequireRole("HrAdministrator", "HrOfficer"))
// A claim with specific values
.AddPolicy("CanEditSalary", policy =>
policy.RequireClaim("permission", "salary:write"))
// Arbitrary logic over the caller's claims
.AddPolicy("CompletedDataTraining", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c =>
c.Type == "training" && c.Value == "data-handling-2025")))
// Applied to every endpoint unless it opts out
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
// ---
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController(IEmployeeService employees) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "HrStaff")]
public async Task<ActionResult<IReadOnlyList<EmployeeResponse>>> List(
CancellationToken cancellationToken)
{
return Ok(await employees.ListAsync(cancellationToken));
}
[HttpPut("{id:int}/salary")]
[Authorize(Policy = "CanEditSalary")]
public async Task<IActionResult> UpdateSalary(
int id,
UpdateSalaryRequest request,
CancellationToken cancellationToken)
{
await employees.UpdateSalaryAsync(id, request.AnnualSalary, cancellationToken);
return NoContent();
}
[HttpGet("public-directory")]
[AllowAnonymous]
public async Task<ActionResult<IReadOnlyList<DirectoryEntry>>> PublicDirectory(
CancellationToken cancellationToken)
{
return Ok(await employees.ListDirectoryAsync(cancellationToken));
}
}- AddAuthorizationBuilder gives a compact way to register policies. Each AddPolicy names a rule that endpoints then refer to by string.
- RequireRole and RequireClaim cover the common cases. RequireAssertion takes a function when the rule needs logic that the built-in helpers cannot express.
- A fallback policy applies to endpoints with no authorization attribute at all. Setting it to require an authenticated user turns a forgotten attribute into a closed door instead of an open one, which is the safer direction for a mistake to fail in.
- Because of that fallback, anything genuinely public needs [AllowAnonymous] written on purpose. That is a feature: a reader can see which endpoints were intended to be open.
- Policy names are strings, so a typo is a runtime failure. Declaring them as constants in one class removes that whole category of bug.
// The requirement carries no logic, only intent.
public sealed class CanManageEmployeeRequirement : IAuthorizationRequirement
{
}
public sealed class CanManageEmployeeHandler
: AuthorizationHandler<CanManageEmployeeRequirement, Employee>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CanManageEmployeeRequirement requirement,
Employee employee)
{
string? subject = context.User.FindFirst("sub")?.Value;
bool isOwnRecord = subject is not null && employee.UserSubject == subject;
bool isTheirLineManager = subject is not null && employee.LineManagerSubject == subject;
bool isHrAdministrator = context.User.IsInRole("HrAdministrator");
if (isOwnRecord || isTheirLineManager || isHrAdministrator)
{
context.Succeed(requirement);
}
// Do nothing on failure. Another handler for the same requirement
// may still succeed it.
return Task.CompletedTask;
}
}
// Registration
builder.Services.AddSingleton<IAuthorizationHandler, CanManageEmployeeHandler>();
// ---
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
int id,
UpdateEmployeeRequest request,
[FromServices] IAuthorizationService authorization,
CancellationToken cancellationToken)
{
Employee? employee = await employees.GetEntityAsync(id, cancellationToken);
if (employee is null)
{
return NotFound();
}
AuthorizationResult decision = await authorization.AuthorizeAsync(
User, employee, new CanManageEmployeeRequirement());
if (!decision.Succeeded)
{
return Forbid();
}
await employees.UpdateAsync(employee, request, cancellationToken);
return NoContent();
}- AuthorizationHandler with two type arguments is the resource-based form. The second argument is the object the decision is about, and it arrives already loaded.
- The handler states three separate grounds for access in readable terms. Keeping them as named booleans means the next reader can see the rule rather than decoding a long condition.
- context.Succeed marks the requirement met. Failing is expressed by doing nothing, because several handlers may exist for one requirement and any of them succeeding is enough. Calling context.Fail() is a veto and overrides other handlers — reach for it only when you mean that.
- The record has to be loaded before the check, which means the order in the action matters: not found first, then authorization, then the work.
- Forbid() returns 403 because the caller is known and refused. Returning 404 here instead is a defensible choice when you would rather not confirm the record exists — decide which you want and apply it consistently.
Summary
- Roles are coarse membership labels, claims are individual facts, and policies are named rules registered once and applied by name
- Policies keep the rule in one place and make an endpoint state the capability it needs rather than the group it expects
- Ownership questions need the record loaded, so they are answered by a resource-based handler and IAuthorizationService inside the action
- In a handler, succeeding is explicit and failing is usually silence; context.Fail is a veto over other handlers
- A fallback policy makes a missing attribute fail closed, and hiding a control in the interface is design rather than access control
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Add a second ground for access
Extend CanManageEmployeeHandler so that a member of the PayrollTeam role may also edit any employee, but only while the employee is not marked confidential.
Write it so the existing three grounds keep working, and decide whether the confidential flag should call Fail or should instead leave the requirement unmet.
Show solution
The straightforward version adds a fourth boolean: in the PayrollTeam role and the employee is not confidential. Add it to the existing condition and the other grounds are unaffected.
The Fail question is the interesting part. If confidential records must never be editable by payroll regardless of any other rule, use context.Fail(), because that vetoes the requirement even if another handler would succeed it. If the flag only means payroll has no special access, leave it out of the condition and let HR administrators through as before.
That choice is a policy decision disguised as a code decision, and it is worth stating explicitly in the handler with a comment. A future reader cannot tell from the syntax alone whether the veto was intended.
Think about it
Why a role cannot answer the question
A manager holds the role LineManager. An endpoint is protected with [Authorize(Roles = "LineManager")]. What can this manager now do that they should not, and what is the smallest change that fixes it?
Show solution
They can edit any employee in the company, because the role says they manage someone, not whom. Every line manager in the organisation passes the same check.
The smallest honest fix is a resource-based check inside the action: load the employee, confirm the caller is that employee's line manager, and return 403 otherwise. The role attribute can stay as a cheap first gate.
It is worth noticing why this bug survives testing. Each manager sees a correct list of their own team, so the interface behaves properly and every manual test passes. Only a request naming another team's employee id exposes it, and nobody makes that request by accident.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.