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.
Why Policy-Based Authorization
Role checks scattered through controller code become unmaintainable:
// 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
// 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
// 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:
// 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):
// 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
// 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:
// 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
IAuthorizationHandlerimplementations. Endpoints declareRequireAuthorization("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
RequireRoleandRequireClaim. Complex decisions (resource-based, cross-field logic) go inAuthorizationHandlerimplementations. Test handlers directly in unit tests. The result is authorization logic that is auditable, changeable in one place, and validated by tests.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.