Once ASP.NET Core knows who is calling, authorization decides what that caller may do. The framework gives you four building blocks that compose into everything from a simple admin flag to a fully dynamic, multi-tenant permission system: roles, claims, policies backed by requirements and handlers, and resource-based checks that look at the specific object being accessed. This guide walks through all four, shows how they apply in Minimal APIs and Blazor, and covers testing authorization logic in isolation from HTTP.

What Is Authorization in ASP.NET Core?#

Authorization runs after authentication, against the ClaimsPrincipal that the authentication middleware already attached to HttpContext.User. At its center is IAuthorizationService, a framework-registered service with two AuthorizeAsync overloads: one that evaluates a named policy, one that evaluates an explicit list of requirements, both optionally against a specific resource object. Everything else β€” [Authorize], RequireAuthorization(), roles and claims shortcuts β€” is sugar that ultimately builds an AuthorizationPolicy and hands it to that same service. Understanding IAuthorizationService first makes the rest of the system easier to reason about, because declarative attributes and imperative service calls are two doors into the same evaluation engine.

How the Authorization Pipeline Works#

The authorization middleware inspects the matched endpoint's metadata and combines it into a policy before IAuthorizationService ever runs:

Endpoint metadataPolicy used
NoneThe fallback policy, if one is configured; otherwise no authorization is required
[Authorize] / RequireAuthorization(), no policy nameThe default policy (requires an authenticated user, unless changed)
[Authorize(Policy = "Name")] / RequireAuthorization("Name")The named policy
[Authorize(Roles = "Admin")]A policy built from the specified roles
[AllowAnonymous] / AllowAnonymous()Authorization is not enforced for the endpoint

The fallback and default policies are not the same thing and are never combined: an endpoint with a bare [Authorize] uses the default policy, and only an endpoint with no authorization metadata at all falls back to the fallback policy. Static files served before the authorization middleware runs are never covered by either. For how the credential itself gets onto HttpContext.User in the first place, see authentication in ASP.NET Core.

Getting Started: [Authorize] and RequireAuthorization#

Controllers and Razor components use the [Authorize] attribute; Minimal API endpoints use the fluent RequireAuthorization() extension, and both accept a policy name:

C#
[Authorize]
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
    [Authorize(Policy = "CanManageOrders")]
    [HttpDelete("{id:int}")]
    public IActionResult Delete(int id) => NoContent();
}
C#
var orders = app.MapGroup("/orders").RequireAuthorization();

orders.MapDelete("/{id:int}", (int id) => Results.NoContent())
      .RequireAuthorization("CanManageOrders");

// A single endpoint can also use the attribute directly:
app.MapGet("/orders/mine", [Authorize] (ClaimsPrincipal user) => user.Identity?.Name);

RequireAuthorization() on a MapGroup applies to every endpoint in the group, and a more specific call on an individual endpoint adds to, rather than replaces, the group's requirements β€” all of them must pass.

Role-Based Authorization#

Roles are the simplest model: a claim of type role (or whatever RoleClaimType is configured to) whose values the framework checks with ClaimsPrincipal.IsInRole. [Authorize(Roles = "Admin,SuperUser")] is an OR: any one of the listed roles is enough. Stacking multiple [Authorize(Roles = "...")] attributes on the same target is an AND: the user must satisfy every attribute.

C#
[Authorize(Roles = "Admin,SuperUser")] // Admin OR SuperUser
[Authorize(Roles = "EmployeeOfTheMonth")] // AND EmployeeOfTheMonth
public IActionResult ManagerLounge() => Ok();

The same OR/AND rules apply to the policy-based equivalent, RequireRole: a single call with multiple arguments is an OR, and chaining .RequireRole("Admin").RequireRole("SuperUser") on the same policy builder is an AND, because each call adds a separate requirement. If you use ASP.NET Core Identity, call AddRoles<IdentityRole>() when configuring Identity to register the role store and RoleManager<TRole> that back this model; without it, IsInRole still works against any role claims you issue yourself, for example from an external OIDC provider's token.

Claims-Based Authorization#

Roles are really a special case of the more general claims model: any key/value pair on the principal, such as department = "Finance" or subscription-tier = "Enterprise", can drive an authorization decision. Register a claims policy with RequireClaim, which checks for the claim's presence and, optionally, one of a set of allowed values:

C#
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("FinanceOnly", policy => policy.RequireClaim("department", "Finance"))
    .AddPolicy("HasSubscription", policy => policy.RequireClaim("subscription-tier"));

Claims usually come from the identity provider's token, but you can add or reshape them after authentication with an IClaimsTransformation implementation, which runs once per request before authorization evaluates. That is the right place to turn an external user ID into an internal permission claim looked up from your own database, rather than re-querying the database inside every authorization handler.

Policy-Based Authorization: Requirements and Handlers#

Policies exist for logic that a role or claim check cannot express on its own. A policy is one or more requirements β€” classes implementing the marker interface IAuthorizationRequirement β€” each evaluated by an authorization handler:

C#
public class MinimumAgeRequirement(int minimumAge) : IAuthorizationRequirement
{
    public int MinimumAge { get; } = minimumAge;
}

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dateOfBirthClaim = context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);
        if (dateOfBirthClaim is not null &&
            DateTime.Parse(dateOfBirthClaim.Value).AddYears(requirement.MinimumAge) <= DateTime.UtcNow)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AtLeast21", policy => policy.Requirements.Add(new MinimumAgeRequirement(21)));

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

A handler indicates success by calling context.Succeed(requirement); it should generally not call context.Fail() for a simple "no" answer, because other handlers for the same requirement may still succeed β€” a requirement can have multiple handlers evaluated on an OR basis (any one succeeding satisfies the requirement), while multiple requirements on one policy are always evaluated on an AND basis. AuthorizationOptions .InvokeHandlersAfterFailure, true by default, keeps running every handler even after one calls context.Fail(), which lets handlers log side effects consistently. For simple inline logic, RequireAssertion skips the requirement/handler ceremony entirely with a Func<AuthorizationHandlerContext, bool>.

Attribute-Driven Policies with IAuthorizationRequirementData#

IAuthorizationRequirementData, added in .NET 8, lets an attribute carry its own requirement instead of you registering a separate named policy:

C#
public class MinimumAgeAuthorizeAttribute(int age) : AuthorizeAttribute, IAuthorizationRequirementData
{
    public int Age { get; } = age;

    public IEnumerable<IAuthorizationRequirement> GetRequirements()
    {
        yield return new MinimumAgeRequirement(Age);
    }
}

app.MapGet("/lounge", () => "Welcome").RequireAuthorization(new MinimumAgeAuthorizeAttribute(21));

In ASP.NET Core 8.0 through 10.0, IAuthorizationRequirementData attributes are enforced only on Minimal API and other routed endpoints; .NET 11 extends support to SignalR hubs and hub methods, MVC controllers and actions, and Blazor's AuthorizeView and AuthorizeRouteView components. If your app targets an earlier release and needs the same pattern on a controller action, register a conventional named policy instead.

Resource-Based Authorization with IAuthorizationService#

Attribute-based checks run before model binding, so they cannot see the specific entity a request is about β€” you cannot decide from [Authorize] alone whether this user may edit this order. That requires imperative, resource-based authorization through IAuthorizationService, injected wherever you need it:

C#
public class OrderService(IAuthorizationService authorizationService)
{
    public async Task<IResult> UpdateAsync(Order order, ClaimsPrincipal user)
    {
        var result = await authorizationService.AuthorizeAsync(user, order, "SameOwnerPolicy");
        if (!result.Succeeded)
        {
            return Results.Forbid();
        }

        // Apply the update.
        return Results.NoContent();
    }
}

The handler for a resource-based requirement declares both the requirement and resource types:

C#
public class SameOwnerRequirement : IAuthorizationRequirement { }

public class OrderAuthorizationHandler : AuthorizationHandler<SameOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, SameOwnerRequirement requirement, Order resource)
    {
        if (context.User.Identity?.Name == resource.OwnerName)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

For CRUD-shaped checks, the built-in OperationAuthorizationRequirement helper avoids writing one requirement class per verb: define Operations.Create, .Read, .Update and .Delete once, branch on requirement.Name inside a single handler, and call AuthorizeAsync(user, resource, Operations.Delete) at each call site.

Building Permission-Based Authorization#

Role and claim checks hardcode a name at compile time. Larger systems usually want permissions β€” fine-grained, data-driven strings such as "orders.delete" β€” checked without redeploying every time a permission is added. Combine a permission claim (populated at sign-in from your own store) with a generic requirement and handler:

C#
public class PermissionRequirement(string permission) : IAuthorizationRequirement
{
    public string Permission { get; } = permission;
}

public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, PermissionRequirement requirement)
    {
        if (context.User.HasClaim("permission", requirement.Permission))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

app.MapDelete("/orders/{id:int}", (int id) => Results.NoContent())
   .RequireAuthorization(policy => policy.Requirements.Add(new PermissionRequirement("orders.delete")));

For a catalog large enough that you don't want to register a named policy per permission, implement a custom IAuthorizationPolicyProvider that builds an AuthorizationPolicy on demand from the policy name itself (for example, treating any policy named "Permission:orders.delete" as a request for that permission), instead of pre-registering every combination at startup.

Multi-Tenant Authorization#

In a multi-tenant system, a permission is not enough on its own β€” a support agent with orders.delete for tenant A must not delete an order that belongs to tenant B. Fold the tenant check into a resource-based handler alongside the permission check, comparing a tenant_id claim against the resource's own tenant, never a client-supplied value:

C#
protected override Task HandleRequirementAsync(
    AuthorizationHandlerContext context, PermissionRequirement requirement, ITenantScoped resource)
{
    var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
    if (tenantClaim == resource.TenantId && context.User.HasClaim("permission", requirement.Permission))
    {
        context.Succeed(requirement);
    }

    return Task.CompletedTask;
}

Keep the tenant identifier out of the request body or route entirely where you can, and derive it only from the validated token or session, so a manipulated payload cannot widen access across tenants.

Testing Authorization#

Handlers are plain classes with no HTTP dependency, so unit test them directly by constructing an AuthorizationHandlerContext with a fabricated ClaimsPrincipal, the requirement under test, and an optional resource:

C#
[Fact]
public async Task Handler_Succeeds_For_Matching_Owner()
{
    var user = new ClaimsPrincipal(new ClaimsIdentity(
        [new Claim(ClaimTypes.Name, "alice")], "TestAuth"));
    var requirement = new SameOwnerRequirement();
    var context = new AuthorizationHandlerContext([requirement], user, new Order { OwnerName = "alice" });

    await new OrderAuthorizationHandler().HandleAsync(context);

    Assert.True(context.HasSucceeded);
}

For integration tests, register a lightweight test AuthenticationHandler in a WebApplicationFactory-based fixture that issues a predictable ClaimsPrincipal for every request, so you exercise the real authorization pipeline β€” including policy selection and [Authorize] metadata β€” without depending on a live identity provider.

Best Practices#

  • Start from a fallback policy that requires authentication, and use roles or claims only where they truly map to your domain, not as a stand-in for permissions.
  • Prefer policies over role strings once you have more than a handful of roles. Policies centralize the rule; role strings scatter it across every attribute.
  • Never trust a client-supplied tenant or resource ID for authorization. Compare it against a value derived from the authenticated principal or loaded from the database.
  • Keep handlers side-effect-light and fast. They can run multiple times per request and should not perform expensive I/O without caching.
  • Use resource-based authorization for anything ownership-shaped. [Authorize] alone cannot see the entity.
  • Unit test handlers directly. They have no HTTP dependency, so there is no reason to test them only through full integration tests.

Common Pitfalls#

  • Confusing the default and fallback policies. A bare [Authorize] always uses the default policy, never the fallback, even on the same endpoint.
  • Forgetting that multiple [Authorize] attributes are ANDed. Stacking them by accident makes an endpoint stricter than intended.
  • Calling context.Fail() for an ordinary "not met" result. That forces the whole policy to fail even if another handler for the same requirement would have succeeded; reserve Fail() for cases that must veto every other handler.
  • Doing resource-based checks inside the resource-loading action only after the data is already fetched and returned to the client, instead of before assembling the response.
  • Relying on IAuthorizationRequirementData attributes on MVC controllers or SignalR hubs before .NET 11. They are silently not enforced there on earlier target frameworks.
  • Hardcoding tenant or organization IDs from route parameters instead of validating them against the caller's own claims.

Roles vs Claims vs Policies: Choosing an Approach#

ApproachGranularityWhere it's definedBest for
RolesCoarse (one name per group of users)[Authorize(Roles = "...")] or RequireRoleSmall, stable sets of job functions (Admin, Editor)
ClaimsFine (any key/value pair)RequireClaimAttributes issued by the identity provider (department, tier)
Policies with requirementsAs fine as your handler logicAddPolicy + AuthorizationHandler<T>Reusable business rules, especially with external data
Resource-based (IAuthorizationService)Per-entityCalled imperatively in codeOwnership and per-record access decisions

Frequently Asked Questions#

What is the difference between the default policy and the fallback policy?#

The default policy applies when an endpoint opts in with a bare [Authorize] or RequireAuthorization() and no policy name; it requires an authenticated user unless you change it. The fallback policy applies only to endpoints that carry no authorization metadata at all, and the two are never combined for the same endpoint.

When should I use roles instead of policies?#

Roles are fine for a small, stable set of broad job functions where the check really is "is this user an Admin?" Reach for policies as soon as the rule involves more than membership in a named group β€” combining claims, checking a resource, or applying business logic that might change independently of who has which role.

How do I authorize access to a specific record, not just an endpoint?#

Use resource-based authorization: inject IAuthorizationService and call AuthorizeAsync(user, resource, policyName) after loading the entity, inside a handler that implements AuthorizationHandler<TRequirement, TResource>. Attribute-based checks such as [Authorize] run before the entity is loaded, so they cannot express this by themselves.

Can Blazor WebAssembly enforce authorization on its own?#

Only as a UI convenience. <AuthorizeView> and [Authorize] in a WebAssembly app hide or block navigation to markup, but the compiled client code still ships to the browser, so any API the app calls must independently enforce authorization server-side; never treat client-side checks as the real security boundary.

How do I unit test a custom authorization handler?#

Construct an AuthorizationHandlerContext directly with the requirement, a ClaimsPrincipal you build in the test, and an optional resource object, call the handler's HandleAsync (or HandleRequirementAsync if you invoke it directly), and assert on context.HasSucceeded. No web host or HTTP call is required.

Summary#

  • IAuthorizationService is the engine behind every authorization feature; [Authorize], roles, claims and policies all end up calling it.
  • Roles and claims cover simple, static checks; policies with requirements and handlers cover reusable business rules; resource-based authorization covers per-entity ownership decisions.
  • IAuthorizationRequirementData lets an attribute carry its own requirement, with broader framework support (SignalR, MVC, Blazor) arriving in .NET 11.
  • Multi-tenant and permission-based designs both build on the same requirement/handler pattern β€” the logic just reads from claims and resources instead of a hardcoded role name.
  • Handlers are plain classes: test them directly without spinning up a web host.

Further Reading#