.NET & C# Development · Lesson 36 of 92
Policy-Based Auth — Complex Rules Without Spaghetti Code
Why Roles Aren't Enough
A [Authorize(Roles = "Admin")] attribute gets you started, but roles collapse the moment requirements get specific:
- "User can edit their own posts, Admins can edit any post"
- "Premium subscribers can export, but only if their subscription is active"
- "Managers can approve orders under $10,000 — Directors approve the rest"
Stuffing these into roles means role explosion or scattered if checks in every handler. Policies give you named, composable, testable rules instead.
Registering Policies
// Program.cs
builder.Services.AddAuthorization(options =>
{
// Simple claim check
options.AddPolicy("HasVerifiedEmail", policy =>
policy.RequireClaim("email_verified", "true"));
// Claim + role combo
options.AddPolicy("PremiumUser", policy =>
{
policy.RequireRole("User");
policy.RequireClaim("subscription_tier", "premium", "enterprise");
});
// Arbitrary lambda — good for one-off rules
options.AddPolicy("Over18", policy =>
policy.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == "age" && int.Parse(c.Value) >= 18)));
});Apply to controllers or minimal API endpoints:
[Authorize(Policy = "PremiumUser")]
[HttpGet("export")]
public IActionResult Export() => Ok("your data...");
// Minimal API
app.MapGet("/export", () => "your data...")
.RequireAuthorization("PremiumUser");Custom Requirements and Handlers
For anything non-trivial, split the rule into a requirement (what must be true) and a handler (how to evaluate it).
// The requirement — just a marker with optional data
public class MinimumOrderApprovalLimitRequirement : IAuthorizationRequirement
{
public decimal MaxAmount { get; }
public MinimumOrderApprovalLimitRequirement(decimal maxAmount) => MaxAmount = maxAmount;
}
// The handler — where the logic lives
public class OrderApprovalLimitHandler
: AuthorizationHandler<MinimumOrderApprovalLimitRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumOrderApprovalLimitRequirement requirement)
{
var claim = context.User.FindFirst("approval_limit");
if (claim is null)
return Task.CompletedTask; // fail — don't call Succeed
if (decimal.TryParse(claim.Value, out var limit) && limit >= requirement.MaxAmount)
context.Succeed(requirement);
return Task.CompletedTask;
}
}Register both:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanApproveStandardOrders", policy =>
policy.Requirements.Add(new MinimumOrderApprovalLimitRequirement(10_000m)));
options.AddPolicy("CanApproveEnterpriseOrders", policy =>
policy.Requirements.Add(new MinimumOrderApprovalLimitRequirement(100_000m)));
});
// Handlers are registered with DI — one handler can cover multiple requirements
builder.Services.AddSingleton<IAuthorizationHandler, OrderApprovalLimitHandler>();Resource-Based Authorization
The [Authorize] attribute fires before the controller action executes — it doesn't have access to the resource yet. For ownership checks ("is this their post?"), inject IAuthorizationService and call it inside the handler.
public class PostOwnerRequirement : IAuthorizationRequirement { }
public class PostOwnerHandler : AuthorizationHandler<PostOwnerRequirement, Post>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PostOwnerRequirement requirement,
Post resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (resource.AuthorId == userId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Register it
builder.Services.AddSingleton<IAuthorizationHandler, PostOwnerHandler>();Call it from the controller:
[ApiController]
[Route("posts")]
public class PostsController : ControllerBase
{
private readonly IPostRepository _posts;
private readonly IAuthorizationService _authz;
public PostsController(IPostRepository posts, IAuthorizationService authz)
{
_posts = posts;
_authz = authz;
}
[HttpPut("{id}")]
[Authorize] // must be authenticated, policy evaluated below
public async Task<IActionResult> Update(int id, UpdatePostRequest request)
{
var post = await _posts.GetByIdAsync(id);
if (post is null) return NotFound();
var result = await _authz.AuthorizeAsync(User, post, new PostOwnerRequirement());
if (!result.Succeeded) return Forbid();
post.Title = request.Title;
post.Body = request.Body;
await _posts.SaveAsync(post);
return NoContent();
}
}Combining Multiple Requirements
A policy requires all requirements to succeed. For OR logic, put the branching inside a single handler:
public class EditPostHandler : AuthorizationHandler<PostOwnerRequirement, Post>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PostOwnerRequirement requirement,
Post resource)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
// Owner OR Admin can edit
if (resource.AuthorId == userId || context.User.IsInRole("Admin"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}Testing Authorization Handlers
Handlers are plain classes — test them directly, no HTTP stack needed:
public class PostOwnerHandlerTests
{
[Fact]
public async Task Succeeds_WhenUserIsOwner()
{
var handler = new PostOwnerHandler();
var post = new Post { AuthorId = "user-123" };
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "user-123") };
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var requirement = new PostOwnerRequirement();
var context = new AuthorizationHandlerContext(
new[] { requirement }, principal, post);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task Fails_WhenUserIsNotOwner()
{
var handler = new PostOwnerHandler();
var post = new Post { AuthorId = "user-999" };
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "user-123") };
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var requirement = new PostOwnerRequirement();
var context = new AuthorizationHandlerContext(
new[] { requirement }, principal, post);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
}Default Policy and Fallback Policy
builder.Services.AddAuthorization(options =>
{
// Applied when [Authorize] has no policy specified
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("email_verified", "true")
.Build();
// Applied to every endpoint that has NO [Authorize] or [AllowAnonymous]
// Locks down the whole app by default — opt out with [AllowAnonymous]
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});Key Takeaways
- Use
RequireClaim/RequireRolefor simple, static rules - Use
RequireAssertionfor quick one-liners that don't need DI - Create
IAuthorizationRequirement+IAuthorizationHandlerwhen the rule is reusable or needs services - Use
IAuthorizationService.AuthorizeAsyncwith a resource for ownership/row-level checks - Handlers are pure classes — write unit tests for them without spinning up a server