ASP.NET Core supports two authorisation models: role-based and policy-based. Role-based authorisation is simpler but less flexible. Policy-based authorisation handles complex requirements cleanly. Understanding when to use each — and when to combine them — is key to building a maintainable security model.
Role-Based Authorisation
Roles are the traditional approach. A user belongs to one or more roles, and endpoints require specific roles:
app.MapGet("/admin/dashboard", () =>
{
return Results.Ok("Admin dashboard");
}).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
app.MapGet("/reports", () =>
{
return Results.Ok("Reports");
}).RequireAuthorization(new AuthorizeAttribute { Roles = "Admin,Manager" });
In controllers:
[Authorize(Roles = "Admin")]
public class AdminController : Controller
{
[Authorize(Roles = "SuperAdmin")]
public IActionResult DangerousAction()
{
// Must be both Admin AND SuperAdmin
return View();
}
}
Roles work well when your permissions map cleanly to job titles or organisational structure. But they break down quickly. "Admin" often means different things in different contexts. "Can this user approve expenses over ten thousand pounds?" isn't something roles express well.
Policy-Based Authorisation
Policies decouple authorisation logic from role names. Define what a policy requires, then apply it by name:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanViewReports", policy =>
policy.RequireRole("Admin", "Manager", "Analyst"));
options.AddPolicy("CanApproveExpenses", policy =>
policy.RequireClaim("department", "finance")
.RequireClaim("seniority", "senior", "lead"));
options.AddPolicy("MinimumAge", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
});
Apply policies to endpoints:
app.MapGet("/reports", () => Results.Ok("Reports"))
.RequireAuthorization("CanViewReports");
app.MapPost("/expenses/approve", () => Results.Ok("Approved"))
.RequireAuthorization("CanApproveExpenses");
Custom Requirements and Handlers
For logic that goes beyond simple role or claim checks, implement IAuthorizationRequirement and AuthorizationHandler<T>:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge)
{
MinimumAge = minimumAge;
}
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst("date_of_birth");
if (dateOfBirthClaim is null)
return Task.CompletedTask; // Requirement not met
var dateOfBirth = DateTime.Parse(dateOfBirthClaim.Value);
var age = DateTime.Today.Year - dateOfBirth.Year;
if (dateOfBirth.Date > DateTime.Today.AddYears(-age))
age--;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Register the handler:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
Not calling context.Succeed() means the requirement isn't met. Not calling context.Fail() means other handlers for the same requirement can still succeed. This distinction matters when multiple handlers evaluate the same requirement.
Resource-Based Authorisation
Sometimes authorisation depends on the specific resource being accessed. "Can this user edit this document?" requires knowing both the user and the document:
public class DocumentAuthorisationHandler
: AuthorizationHandler<EditRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
EditRequirement requirement,
Document resource)
{
if (resource.AuthorId == context.User.FindFirstValue(
ClaimTypes.NameIdentifier))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public class EditRequirement : IAuthorizationRequirement { }
Evaluate it in your endpoint:
app.MapPut("/documents/{id}", async (int id,
DocumentService docService,
IAuthorizationService authService,
ClaimsPrincipal user) =>
{
var document = await docService.GetAsync(id);
if (document is null)
return Results.NotFound();
var result = await authService.AuthorizeAsync(
user, document, new EditRequirement());
if (!result.Succeeded)
return Results.Forbid();
// Proceed with edit
return Results.Ok();
});
Combining Approaches
Roles and policies aren't mutually exclusive. Use roles as building blocks within policies:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ContentManagement", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Editor", "Admin");
policy.Requirements.Add(new ActiveAccountRequirement());
});
});
This policy requires authentication, membership in specific roles, and a custom requirement that checks the account is active. Each layer adds a constraint.
When to Use Which
Use roles when your permission model is simple and maps to organisational structure. A small internal tool with Admin, Editor, and Viewer roles is a good fit.
Use policies when permissions depend on claims, resource ownership, time of day, subscription tier, or any logic more complex than "is the user in this group?" Policies scale better and keep authorisation logic testable.
Use resource-based authorisation when the decision depends on the specific item being accessed. Document ownership, team membership, and organisation-scoped data all fall into this category.
Wrapping Up
Start with policies, even for simple cases. A policy named "CanManageUsers" is more meaningful than Roles = "Admin" scattered across your codebase. When requirements change — and they will — updating a policy definition is far easier than finding every [Authorize(Roles = "Admin")] attribute in your application.