Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 030 / 100

Policy-based authorization: .NET lessons from production

Roles are a start. Policies scale with real business rules.

Expona Admin7 min read

Policy-based authorization

Roles are a start, but real-world applications demand rules that adapt to your business. Policy-based authorization in ASP.NET Core lets you decouple your authorization logic from your controllers, making it reusable, testable, and maintainable across endpoints. In this article, I'll walk through defining policies once and applying them everywhere, building custom requirements for owner-based checks, and testing policies in isolation.

Why move beyond simple role checks

Early on, you might protect endpoints with a single role:

csharp
[Authorize(Roles = "Admin")]
public IActionResult DeleteUser(int id) { }

This works for simple scenarios. But as your platform grows, authorization rules become complex. You need to check not just roles, but claims (tenant IDs, feature flags), resource ownership, time-based access, and conditional logic. Hardcoding these checks in multiple controllers creates maintenance nightmares and duplicates logic.

Policy-based authorization solves this by letting you define a named rule once in your startup configuration, then reference it by name on any endpoint. The policy engine evaluates the rule consistently, and you control the logic in one place.

Define policies once and reuse them everywhere

In your Program.cs, use the AuthorizationBuilder to register policies:

csharp
builder.Services.AddAuthorizationBuilder()
  .AddPolicy("CanRefund", policy =>
    policy
      .RequireRole("Finance")
      .RequireClaim("tenant"));

Now any endpoint can declare this policy by name:

csharp
[Authorize(Policy = "CanRefund")]
public IActionResult RefundOrder(int orderId)
{
  // Only Finance role with a tenant claim can enter
  return Ok("Refund initiated");
}

The same policy applies everywhere. If your business rule changes—say, you now also require a "refund-authority" claim—you update the policy definition once, and every endpoint automatically enforces the new rule. This scales far better than decorating a dozen controllers with identical checks.

You can also define policies with RequireAssertion for inline predicates:

csharp
builder.Services.AddAuthorizationBuilder()
  .AddPolicy("IsWeekday", policy =>
    policy.RequireAssertion(ctx =>
      DateTime.UtcNow.DayOfWeek != DayOfWeek.Saturday &&
      DateTime.UtcNow.DayOfWeek != DayOfWeek.Sunday));

But for anything more complex than a one-liner, move to a custom requirement and handler.

Custom requirements for business-specific logic

Real authorization often depends on data: "Can this user edit this order?" To answer that, you need to know who owns the order. That's where custom requirements shine.

First, create a class implementing IAuthorizationRequirement:

csharp
public class IsOrderOwnerRequirement : IAuthorizationRequirement
{
}

Next, implement a handler extending AuthorizationHandler<T>:

csharp
public class IsOrderOwnerHandler 
  : AuthorizationHandler<IsOrderOwnerRequirement>
{
  private readonly IOrderService _orderService;

  public IsOrderOwnerHandler(IOrderService orderService)
  {
    _orderService = orderService;
  }

  protected override async Task HandleRequirementAsync(
    AuthorizationHandlerContext context,
    IsOrderOwnerRequirement requirement)
  {
    // Extract the order ID from route or request
    var orderId = context.Resource as int?;
    if (orderId == null)
    {
      context.Fail();
      return;
    }

    var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (userId == null)
    {
      context.Fail();
      return;
    }

    var order = await _orderService.GetOrderAsync(orderId.Value);
    if (order == null || order.UserId != userId)
    {
      context.Fail();
      return;
    }

    context.Succeed(requirement);
  }
}

Register both the requirement and handler in Program.cs:

csharp
builder.Services.AddAuthorizationBuilder()
  .AddPolicy("IsOrderOwner", policy =>
    policy.AddRequirements(new IsOrderOwnerRequirement()));

builder.Services.AddSingleton<
  IAuthorizationHandler, IsOrderOwnerHandler>();

Now decorate your endpoint:

csharp
[Authorize(Policy = "IsOrderOwner")]
public IActionResult GetMyOrder(int orderId)
{
  return Ok("Order details");
}

The handler runs automatically. If the user doesn't own the order, the request fails before your action runs. The beauty here is separation of concerns: authorization logic lives in the handler, not scattered across endpoints.

Resource-based authorization with IAuthorizationService

Sometimes you load a resource in your action, then decide whether the user can access it. That's resource-based authorization, and it's cleaner than passing data to the handler context.

Inject IAuthorizationService and call AuthorizeAsync with the resource:

csharp
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
  private readonly IOrderService _orderService;
  private readonly IAuthorizationService _authorizationService;

  public OrdersController(
    IOrderService orderService,
    IAuthorizationService authorizationService)
  {
    _orderService = orderService;
    _authorizationService = authorizationService;
  }

  [HttpGet("{id}")]
  public async Task<IActionResult> GetOrder(int id)
  {
    var order = await _orderService.GetOrderAsync(id);
    if (order == null)
      return NotFound();

    var result = await _authorizationService.AuthorizeAsync(
      User, order, "ViewOrder");

    if (!result.Succeeded)
      return Forbid();

    return Ok(order);
  }
}

Create a handler for the "ViewOrder" policy that accepts an Order resource:

csharp
public class ViewOrderRequirement : IAuthorizationRequirement
{
}

public class ViewOrderHandler 
  : AuthorizationHandler<ViewOrderRequirement, Order>
{
  protected override Task HandleRequirementAsync(
    AuthorizationHandlerContext context,
    ViewOrderRequirement requirement,
    Order resource)
  {
    var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    if (resource.UserId == userId || 
        context.User.IsInRole("Admin"))
    {
      context.Succeed(requirement);
    }
    else
    {
      context.Fail();
    }

    return Task.CompletedTask;
  }
}

Register it:

csharp
builder.Services.AddSingleton<
  IAuthorizationHandler, ViewOrderHandler>();

builder.Services.AddAuthorizationBuilder()
  .AddPolicy("ViewOrder", p => 
    p.AddRequirements(new ViewOrderRequirement()));

This pattern shines for multi-tenant systems, complex ownership rules, or status-based checks. You load the actual entity, then let the handler decide based on the user and the resource state.

Test policies directly without spinning up a server

One of the biggest wins with policy-based authorization is testability. Your handlers are plain classes. You can unit test them in isolation without an HTTP server or a full application context.

Here's a test for the IsOrderOwnerHandler:

csharp
[TestFixture]
public class IsOrderOwnerHandlerTests
{
  private IsOrderOwnerHandler _handler;
  private Mock<IOrderService> _mockOrderService;

  [SetUp]
  public void Setup()
  {
    _mockOrderService = new Mock<IOrderService>();
    _handler = new IsOrderOwnerHandler(_mockOrderService.Object);
  }

  [Test]
  public async Task HandleAsync_UserIsOwner_Succeeds()
  {
    // Arrange
    var userId = "user-123";
    var orderId = 456;
    var order = new Order { Id = orderId, UserId = userId };

    _mockOrderService
      .Setup(s => s.GetOrderAsync(orderId))
      .ReturnsAsync(order);

    var context = new AuthorizationHandlerContext(
      new[] { new IsOrderOwnerRequirement() },
      new ClaimsPrincipal(new[] {
        new ClaimsIdentity(new[] {
          new Claim(ClaimTypes.NameIdentifier, userId)
        })
      }),
      orderId);

    // Act
    await _handler.HandleRequirementAsync(
      context, new IsOrderOwnerRequirement());

    // Assert
    Assert.IsTrue(context.HasSucceeded);
  }

  [Test]
  public async Task HandleAsync_UserIsNotOwner_Fails()
  {
    // Arrange
    var userId = "user-123";
    var orderId = 456;
    var order = new Order { Id = orderId, UserId = "user-999" };

    _mockOrderService
      .Setup(s => s.GetOrderAsync(orderId))
      .ReturnsAsync(order);

    var context = new AuthorizationHandlerContext(
      new[] { new IsOrderOwnerRequirement() },
      new ClaimsPrincipal(new[] {
        new ClaimsIdentity(new[] {
          new Claim(ClaimTypes.NameIdentifier, userId)
        })
      }),
      orderId);

    // Act
    await _handler.HandleRequirementAsync(
      context, new IsOrderOwnerRequirement());

    // Assert
    Assert.IsFalse(context.HasSucceeded);
  }
}

No server. No HTTP calls. Just mock your dependencies, build a context, invoke the handler, and assert the outcome. This is why policy-based authorization is so powerful for large teams: authorization rules become testable building blocks.

Common mistakes

  • Mixing handler logic with controller logic. If you're checking authorization facts inside your action method, you've missed the point. Move that to a handler. Your action should trust that the policy passed.
  • Forgetting to test the happy path and the failure path. A handler that never fails is buggy. Always test both success and denial scenarios.
  • Creating a new policy for every edge case. If two policies differ only slightly, consider combining them with a parameter or adding a claim check instead of duplicating logic.
  • Not handling null checks in handlers. If a resource doesn't exist or a claim is missing, fail gracefully, don't throw. A thrown exception is harder to debug than a denied context.
  • Overcomplicating inline policies. RequireAssertion is tempting, but a multi-line lambda is a sign you need a custom handler. Handlers are easier to test and understand.

Key takeaways

  • Define policies once in your authorization builder and reference them by name on any endpoint. This centralizes authorization logic and makes changes consistent across your application.
  • Use custom requirements and handlers when your policy depends on fetching data or running complex logic. Handlers are unit-testable classes that keep your controllers clean.
  • Inject IAuthorizationService and call AuthorizeAsync with a resource when you load an entity in your action and need to decide access based on that entity's state or ownership.
  • Test handlers in isolation with mocks. You don't need a server, a database, or an HTTP request to verify that your authorization rules work correctly.
  • Keep policies focused and named clearly. "CanRefund" is better than "AdminWithTenant." A clear name documents what the policy enforces.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles