Back to blog
Backend Systemsintermediate

Policy-Based Auth — Complex Rules Without Spaghetti Code

Roles break down fast when business rules get complex. Policy-based authorization in ASP.NET Core lets you express any rule — from claim checks to resource ownership — in clean, testable handlers.

LearnixoApril 14, 20264 min read
.NETC#AuthorizationSecurityASP.NET CorePolicies
Share:𝕏

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

C#
// 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:

C#
[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).

C#
// 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:

C#
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.

C#
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:

C#
[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:

C#
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:

C#
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

C#
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 / RequireRole for simple, static rules
  • Use RequireAssertion for quick one-liners that don't need DI
  • Create IAuthorizationRequirement + IAuthorizationHandler when the rule is reusable or needs services
  • Use IAuthorizationService.AuthorizeAsync with a resource for ownership/row-level checks
  • Handlers are pure classes — write unit tests for them without spinning up a server

Enjoyed this article?

Explore the Backend 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.