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:

Example.cs
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:

Example.cs
[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:

Program.cs
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:

Example.cs
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>:

MinimumAgeRequirement.cs
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:

Program.cs
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:

DocumentAuthorisationHandler.cs
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:

Program.cs
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:

Program.cs
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.