Learnixo
Back to blog
AI Systemsintermediate

Policy-Based Authorization in ASP.NET Core

Build flexible, testable authorization policies in ASP.NET Core: requirements, handlers, resource-based authorization, and the patterns that replace role-check spaghetti in production systems.

Asma Hafeez KhanMay 16, 20264 min read
AuthorizationPoliciesASP.NET Core.NETSecurity
Share:𝕏

Why Policy-Based Authorization

Role checks scattered through controller code become unmaintainable:

C#
// Anti-pattern — role checks in controller logic
public async Task<IActionResult> GetPatientRecord(Guid patientId)
{
    if (!User.IsInRole("Doctor") && !User.IsInRole("Nurse"))
        return Forbid();

    var patient = await _repo.GetByIdAsync(patientId);
    if (patient.DepartmentId != Guid.Parse(User.FindFirst("department_id")!.Value))
        return Forbid();  // can only see own department

    return Ok(patient);
}

Problems: duplicated across actions, not testable in isolation, business logic buried in HTTP layer.

Policy-based authorization puts this logic in named, testable handlers.


Basic Policy Setup

C#
// Program.cs
builder.Services.AddAuthorizationBuilder()
    // Simple role check — shorthand policy
    .AddPolicy("DoctorsOnly", p => p.RequireRole("Doctor"))

    // Claim check
    .AddPolicy("ActiveEmployees", p =>
        p.RequireClaim("employment_status", "Active"))

    // Custom requirement
    .AddPolicy("SameDepartment", p =>
        p.AddRequirements(new SameDepartmentRequirement()))

    // Multiple requirements (AND logic — all must pass)
    .AddPolicy("SeniorDoctorInDepartment", p =>
        p.RequireRole("Doctor")
         .RequireClaim("seniority", "Senior", "Consultant")
         .AddRequirements(new SameDepartmentRequirement()));

Custom Requirements and Handlers

C#
// Authorization/Requirements/SameDepartmentRequirement.cs
public sealed class SameDepartmentRequirement : IAuthorizationRequirement { }

// Authorization/Handlers/SameDepartmentHandler.cs
public sealed class SameDepartmentHandler
    : AuthorizationHandler<SameDepartmentRequirement, Patient>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameDepartmentRequirement   requirement,
        Patient                     resource)
    {
        var userDept = context.User.FindFirstValue("department_id");
        if (userDept is not null &&
            resource.DepartmentId.ToString() == userDept)
        {
            context.Succeed(requirement);
        }
        // If not succeeded, the requirement fails — no explicit Fail() needed
        // Fail() is for hard-blocking even if other handlers succeed

        return Task.CompletedTask;
    }
}

// Register the handler
builder.Services.AddScoped<IAuthorizationHandler, SameDepartmentHandler>();

Resource-Based Authorization

When the authorization decision depends on the resource being accessed (not just the user's identity), use resource-based authorization:

C#
// In a handler or endpoint
public sealed class GetPatientHandler
{
    private readonly IAuthorizationService _authz;
    private readonly PatientRepository    _repo;

    public async Task<Result<PatientResponse>> Handle(
        GetPatientQuery query, CancellationToken ct)
    {
        var patient = await _repo.GetByIdAsync(query.PatientId, ct);
        if (patient is null)
            return Result.Failure<PatientResponse>(PatientErrors.NotFound);

        // Resource-based: pass the patient as the resource
        var authResult = await _authz.AuthorizeAsync(
            _currentUser.Principal,
            patient,
            "SameDepartment");

        if (!authResult.Succeeded)
            return Result.Failure<PatientResponse>(AuthErrors.Forbidden);

        return Result.Success(patient.ToResponse());
    }
}

Dynamic Policy with IAuthorizationPolicyProvider

For scenarios where policies are data-driven (different access per ward, per drug schedule):

C#
// Authorization/Providers/WardAccessPolicyProvider.cs
public sealed class WardAccessPolicyProvider : IAuthorizationPolicyProvider
{
    private const string WardPrefix = "WardAccess_";
    private readonly DefaultAuthorizationPolicyProvider _fallback;

    public WardAccessPolicyProvider(IOptions<AuthorizationOptions> options)
        => _fallback = new DefaultAuthorizationPolicyProvider(options);

    public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith(WardPrefix, StringComparison.OrdinalIgnoreCase))
        {
            var ward = policyName[WardPrefix.Length..];
            return new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .RequireClaim("ward_access", ward)
                .Build();
        }

        return await _fallback.GetPolicyAsync(policyName);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
        => _fallback.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
        => _fallback.GetFallbackPolicyAsync();
}

// Usage on endpoint
app.MapGet("/ward/{wardId}/patients", GetWardPatients)
    .RequireAuthorization("WardAccess_Cardiology");

Testing Authorization Policies in Isolation

C#
// Tests/Unit/Authorization/SameDepartmentHandlerTests.cs
public class SameDepartmentHandlerTests
{
    [Fact]
    public async Task Same_department_should_succeed()
    {
        var handler     = new SameDepartmentHandler();
        var patient     = new Patient { DepartmentId = Guid.Parse("dept-1") };
        var requirement = new SameDepartmentRequirement();

        var user    = new ClaimsPrincipal(new ClaimsIdentity(
            [new Claim("department_id", "dept-1")]));
        var context = new AuthorizationHandlerContext(
            [requirement], user, patient);

        await handler.HandleAsync(context);

        context.HasSucceeded.Should().BeTrue();
    }

    [Fact]
    public async Task Different_department_should_not_succeed()
    {
        // Arrange: user in dept-1, patient in dept-2
        // Act / Assert: HasSucceeded == false
    }
}

OR Logic Between Policies

ASP.NET Core policies use AND logic by default. For OR logic, combine in one handler:

C#
// Requirement: Admin OR (Doctor AND SameDepartment)
public sealed class AccessPatientHandler
    : AuthorizationHandler<AccessPatientRequirement, Patient>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        AccessPatientRequirement    requirement,
        Patient                     resource)
    {
        var isAdmin = context.User.IsInRole("Admin");
        var isDoctor = context.User.IsInRole("Doctor");
        var userDept = context.User.FindFirstValue("department_id");
        var sameDept = resource.DepartmentId.ToString() == userDept;

        if (isAdmin || (isDoctor && sameDept))
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

Red Flag / Green Answer

Red Flag: "We have 30 if (User.IsInRole(...)) checks scattered across 15 controllers."

This cannot be audited. When a role is renamed or a new role added, 15 files must be updated. A missed check is a security vulnerability that code review may not catch.

Green Answer:

All authorization logic in named policies and IAuthorizationHandler implementations. Endpoints declare RequireAuthorization("PolicyName") and nothing else. Security audit reviews the handler files — one place.


Key Takeaway

Policy-based authorization replaces scattered role checks with named, testable policies and handlers. Simple policies use RequireRole and RequireClaim. Complex decisions (resource-based, cross-field logic) go in AuthorizationHandler implementations. Test handlers directly in unit tests. The result is authorization logic that is auditable, changeable in one place, and validated by tests.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.