DEV Community

Cover image for Authorization in ASP.NET Core
Rhuturaj Takle
Rhuturaj Takle

Posted on

Authorization in ASP.NET Core

Authorization in ASP.NET Core

A deep-dive walkthrough of authorization in ASP.NET Core — covering role-based and claims-based authorization as the simpler building blocks, policy-based authorization as the modern, general-purpose mechanism built on top of them, writing custom IAuthorizationRequirement/AuthorizationHandler pairs, resource-based authorization for per-instance access decisions a policy alone can't express, imperative authorization via IAuthorizationService, and exactly how the authorization middleware and filters covered elsewhere in this series fit together into one coherent system.


Table of Contents

  1. Introduction
  2. Where Authorization Picks Up: The ClaimsPrincipal from Authentication
  3. Role-Based Authorization
  4. Claims-Based Authorization
  5. Why Policy-Based Authorization Exists
  6. Building a Policy from Requirements
  7. Custom Requirements and Authorization Handlers
  8. Combining Multiple Handlers for One Requirement
  9. Resource-Based Authorization
  10. Imperative Authorization: IAuthorizationService
  11. How Authorization Actually Runs in the Pipeline
  12. Fallback Policies and Requiring Authorization by Default
  13. Common Pitfalls
  14. Quick Reference Table
  15. Conclusion

Introduction

Authorization answers a genuinely different question from authentication — not "who is this," but "is this specific, already-identified person allowed to do this specific thing." This series' Authentication guide covers how HttpContext.User gets populated; this guide covers everything that happens after that point, starting from the simplest possible checks (does this user have this role) and building up to the general-purpose, extensible system ASP.NET Core actually recommends for anything beyond the simplest cases: policy-based authorization, where a named policy is built from one or more requirements, each evaluated by one or more handlers, giving you a genuinely composable way to express access rules that role or claim checks alone can't capture — including rules that depend on the specific resource being accessed, not just the caller's identity in the abstract.

HttpContext.User (populated by AUTHENTICATION, this series' Authentication guide)
        ↓
[Authorize(Roles = "Admin")]           — simplest: a role check
[Authorize(Policy = "MinimumAge")]     — a NAMED POLICY, built from one or more REQUIREMENTS
        ↓                                  each requirement is evaluated by an AuthorizationHandler
     Allowed / Forbidden
Enter fullscreen mode Exit fullscreen mode

1. Where Authorization Picks Up: The ClaimsPrincipal from Authentication

Every authorization check in this guide operates on the SAME ClaimsPrincipal this series' Authentication guide's Section 1 introduces

context.User.IsInRole("Admin");                          // role check
context.User.HasClaim(c => c.Type == "Department");       // claim check
context.User.FindFirst(ClaimTypes.Email)?.Value;           // reading a specific claim's value
Enter fullscreen mode Exit fullscreen mode

This is worth stating as the very first, foundational fact this guide builds on: authorization never independently re-verifies who someone is — it exclusively reads whatever claims authentication already established and populated onto HttpContext.User. If a role or claim genuinely needs to be available for an authorization check, it has to have been included by whatever authentication scheme (cookie, JWT, or otherwise) built that user's identity in the first place — authorization cannot conjure information authentication never provided.

This is precisely the boundary this series' Authentication guide's Section 12 and Middleware guide's Section 10 both point to

Authentication: WHO is this? → populates HttpContext.User
Authorization (this ENTIRE guide): given THAT populated User, is this
  SPECIFIC caller allowed to do THIS specific thing?
Enter fullscreen mode Exit fullscreen mode

Everything in this guide happens strictly after, and strictly in terms of, whatever authentication already established — worth keeping this boundary sharp throughout, since conflating the two (trying to "authorize" by re-checking credentials, or trying to "authenticate" by checking permissions) is a common source of confused, poorly-layered security code.


2. Role-Based Authorization

The simplest, most familiar authorization mechanism

[Authorize(Roles = "Admin")]
public IActionResult DeleteUser(int id) { /* ... */ return Ok(); }

[Authorize(Roles = "Admin,Manager")] // comma-separated — the caller needs ANY ONE of these roles (OR logic)
public IActionResult ViewReports() { /* ... */ return Ok(); }
Enter fullscreen mode Exit fullscreen mode

[Authorize(Roles = "...")] checks whether HttpContext.User.IsInRole(...) returns true for at least one of the listed roles — a comma-separated list is evaluated as OR: the caller needs to satisfy any one of the listed roles, not all of them, to pass this specific check.

Requiring MULTIPLE roles together (AND logic) needs stacked attributes

[Authorize(Roles = "Admin")]
[Authorize(Roles = "SecurityClearanceLevel3")] // stacking TWO [Authorize] attributes = AND logic
public IActionResult HighlySensitiveAction() { /* ... */ return Ok(); }
Enter fullscreen mode Exit fullscreen mode

This is a genuinely easy detail to get backwards: a single [Authorize(Roles = "A,B")] is OR (any one role suffices); two separate [Authorize(Roles = "A")] and [Authorize(Roles = "B")] attributes stacked on the same action is AND (both roles are separately required, since each attribute is its own independent authorization filter, per this series' Filters guide's Section 3, and all authorization filters applied to an action must pass).

Why role-based authorization, while simple, doesn't scale well past a certain point

Roles work well for a small, stable, coarse-grained set of user
  categories ("Admin," "User") — they start to strain once an
  application needs finer-grained, more numerous, or more DYNAMIC access
  rules ("can edit orders placed in the last 24 hours," "has completed
  onboarding," "belongs to the SAME department as the resource being
  accessed") — none of which map cleanly onto a small, fixed set of role names.
Enter fullscreen mode Exit fullscreen mode

This is precisely the limitation that motivates Section 4's policy-based system — roles remain a perfectly valid, simple tool for genuinely coarse-grained checks, but reaching for more and more elaborate role names to express increasingly specific business rules is a real anti-pattern worth recognizing early, rather than a scaling strategy.


3. Claims-Based Authorization

A more general check than roles — inspecting ANY claim, not just a role claim specifically

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("MustBeOver18", policy =>
        policy.RequireClaim("DateOfBirth")); // just requires the claim to EXIST, doesn't check its value yet
});

[Authorize(Policy = "MustBeOver18")]
public IActionResult AgeRestrictedContent() { /* ... */ return Ok(); }
Enter fullscreen mode Exit fullscreen mode

Roles are, structurally, just a specific, conventional type of claim (ClaimTypes.Role) — claims-based authorization generalizes the same idea to any claim type at all, which is genuinely useful once an application's access rules depend on facts about a user beyond a simple role label (department, subscription tier, account status).

Why claims-based authorization alone still can't express a VALUE check cleanly

// RequireClaim can check for a claim's PRESENCE, and can check against a FIXED set of acceptable values:
policy.RequireClaim("SubscriptionTier", "Pro", "Enterprise"); // OK — value must be ONE of these exact strings

// But it CANNOT express something like "the claim's value, parsed as a date, is more than 18 years ago" —
// that requires genuine LOGIC, which is exactly what Section 6's custom requirements/handlers provide
Enter fullscreen mode Exit fullscreen mode

RequireClaim supports checking a claim's presence, and checking its value against a small, fixed set of acceptable literal strings — but any check requiring genuine computation (parsing a date and comparing it, checking a numeric threshold, calling out to another service) is beyond what a declarative claim check alone can express, which is exactly the gap Section 6 closes.


4. Why Policy-Based Authorization Exists

A policy is a NAMED, REUSABLE bundle of one or more requirements

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanEditOrders", policy =>
        policy.RequireRole("Admin").RequireClaim("Department", "Sales")); // MULTIPLE conditions, ONE named policy
});

[Authorize(Policy = "CanEditOrders")] // referenced by NAME, everywhere it's needed
public IActionResult EditOrder(int id) { /* ... */ return Ok(); }
Enter fullscreen mode Exit fullscreen mode

This is the core motivation for the whole policy system: rather than repeating a specific combination of role/claim checks (and the exact reasoning behind them) across every endpoint that needs it, you define the rule once, give it a meaningful name, and reference that name everywhere — genuinely the same "define once, reuse everywhere" discipline this series applies to interfaces and abstract classes, here applied to authorization rules specifically.

Why "policy" is the recommended, general-purpose mechanism, even for simple role checks

Per Microsoft's own current guidance: even a SIMPLE role check is often
  better expressed as a named policy than as a raw [Authorize(Roles = "...")]
  attribute — a named policy centralizes the RULE in one place
  (Program.cs, or a dedicated configuration class), meaning a future
  change to what "Admin" actually requires touches ONE registration,
  not every scattered attribute across the codebase.
Enter fullscreen mode Exit fullscreen mode

This is worth internalizing as the actual, practical reason policies are the recommended default even for cases roles alone could technically handle — the value isn't in policies being able to do something roles can't (for the simple case, they can't); it's in the maintainability of having every authorization rule's actual definition live in one place, decoupled from every point in the codebase that references it by name.


5. Building a Policy from Requirements

AuthorizationPolicyBuilder's fluent methods are all, underneath, adding IAuthorizationRequirement objects to the policy

options.AddPolicy("SeniorStaffOnly", policy => policy
    .RequireRole("Manager")                          // adds a RolesAuthorizationRequirement
    .RequireClaim("YearsOfService")                    // adds a ClaimsAuthorizationRequirement
    .RequireAssertion(context =>                        // adds an inline, LAMBDA-based requirement
        context.User.HasClaim(c => c.Type == "YearsOfService" && int.Parse(c.Value) >= 5)));
Enter fullscreen mode Exit fullscreen mode

Every fluent method on the policy builder (RequireRole, RequireClaim, RequireAssertion, and others) is, underneath, constructing and adding a specific IAuthorizationRequirement to the policy — a policy is genuinely nothing more than a named collection of requirements, all of which must be satisfied (by default, AND logic across every requirement in the policy) for the policy to pass.

RequireAssertion: an inline escape hatch for logic too specific for a dedicated requirement class

policy.RequireAssertion(context =>
{
    var user = context.User;
    return user.IsInRole("Admin") || user.HasClaim("OverrideAccess", "true");
});
Enter fullscreen mode Exit fullscreen mode

For a rule that's simple enough not to warrant its own dedicated, reusable requirement/handler pair (Section 6), RequireAssertion lets you write the logic directly as a lambda — genuinely useful for one-off, application-specific rules, though for anything reused across multiple policies, or anything needing dependency-injected services to evaluate (Section 6's whole point), a proper custom requirement is the better-structured choice.


6. Custom Requirements and Authorization Handlers

The two-part pattern: a requirement (what's being checked) and a handler (how it's checked)

// The REQUIREMENT: a simple, DATA-ONLY marker — what parameters does this check need?
public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}

// The HANDLER: the actual LOGIC — genuinely a DI-resolved class, with full constructor injection support
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dobClaim = context.User.FindFirst(c => c.Type == "DateOfBirth");
        if (dobClaim is not null && DateTime.Parse(dobClaim.Value).AddYears(requirement.MinimumAge) <= DateTime.Today)
        {
            context.Succeed(requirement); // marks THIS requirement as satisfied
        }
        // note: NOT calling context.Fail() here — simply not succeeding leaves it open for
        // ANOTHER handler (Section 7) to potentially satisfy the SAME requirement instead
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

This separation — a lightweight, data-only requirement class, and a separate handler class containing the actual evaluation logic — is deliberate, and mirrors this series' Interfaces guide's contract-versus-implementation separation directly: the requirement declares what is being checked (and carries whatever parameters the check needs, like MinimumAge here); the handler, a genuine DI-resolved class (per this series' ASP.NET Core Dependency Injection guide, following whatever lifetime it's registered with), contains the how, with full access to constructor-injected services (a database context, an external age-verification service, anything the check genuinely needs).

Registering the handler and building a policy from the requirement

builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>(); // register the HANDLER in DI

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("MustBe21", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(21))); // build the POLICY from the requirement directly
});
Enter fullscreen mode Exit fullscreen mode

The handler must be explicitly registered in the DI container (as IAuthorizationHandler, not its concrete type — this matters for Section 7) so the authorization system can discover and invoke it; the policy itself is then built by adding an instance of the requirement (with whatever parameters, like the specific age threshold, this particular policy needs) — worth noting the same MinimumAgeRequirement/MinimumAgeHandler pair could back multiple different policies, each with a different threshold, since the threshold lives on the requirement instance, not hardcoded into the handler.

context.Succeed() vs. context.Fail(): a genuinely important, easy-to-get-wrong distinction

context.Succeed(requirement): marks THIS SPECIFIC requirement as satisfied
  — the overall policy STILL needs every OTHER requirement to also
  succeed (this is Section 5's AND-across-requirements default).
context.Fail(): an EXPLICIT, IMMEDIATE failure of the ENTIRE authorization
  evaluation, REGARDLESS of what any other handler or requirement
  concludes — this is a much stronger, more absolute statement than
  simply not calling Succeed().
Enter fullscreen mode Exit fullscreen mode

This distinction matters enormously for Section 7's multi-handler scenarios: a handler that evaluates its condition and finds it doesn't apply should typically just return without calling either method (leaving room for another handler, per Section 7, to potentially satisfy the same requirement) — calling context.Fail() should be reserved for genuinely absolute, no-exceptions-possible failure conditions, since it overrides every other handler's outcome entirely.


7. Combining Multiple Handlers for One Requirement

Multiple handlers CAN be registered for the SAME requirement type — evaluated with OR logic by default

public class BadgeAccessHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        if (context.User.HasClaim("EmployeeBadge", "true")) // employees BYPASS the age check entirely
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}
// registered ALONGSIDE MinimumAgeHandler from Section 6, for the SAME MinimumAgeRequirement type
builder.Services.AddSingleton<IAuthorizationHandler, BadgeAccessHandler>();
Enter fullscreen mode Exit fullscreen mode

This is precisely why context.Succeed() (Section 6) doesn't immediately conclude the whole authorization check — with two handlers registered for the same requirement type, either one succeeding is enough to satisfy that requirement; this is a genuinely powerful pattern for expressing "satisfy this requirement via ANY of several independent paths" (age-verified OR employee badge, in this example) without needing to hardcode every alternative into one single handler.

Why this differs from the AND-across-DIFFERENT-requirements default

MULTIPLE HANDLERS for the SAME requirement: OR (any ONE succeeding is enough).
MULTIPLE REQUIREMENTS on the same policy (Section 5): AND (EVERY
  requirement must be satisfied by SOME handler).
Enter fullscreen mode Exit fullscreen mode

Worth holding these two, genuinely different combination rules distinctly in mind — they answer different questions ("how many ways can THIS ONE requirement be satisfied" versus "how many DIFFERENT things does THIS policy demand"), and conflating them is a real, common source of confusion about how a complex, multi-requirement, multi-handler policy actually evaluates.


8. Resource-Based Authorization

The gap: a policy alone can't know about the SPECIFIC resource being accessed

// ❌ A simple [Authorize(Policy = "...")] check has NO WAY to express
//    "this user can only edit orders THEY THEMSELVES placed" — it has
//    no access to the SPECIFIC order being requested, only the caller's claims
[Authorize(Policy = "CanEditOwnOrders")]
public IActionResult EditOrder(int id) { /* ... */ return Ok(); }
Enter fullscreen mode Exit fullscreen mode

This is a genuine, structural limitation of attribute-based [Authorize] checks: they run before the action executes (this series' Filters guide's Section 3 covers authorization filters' precise timing), which means they have no access to the specific resource (this particular order, loaded from the database) the action is actually about to operate on — only to the caller's identity in the abstract.

The fix: a requirement that evaluates against a specific resource, checked explicitly within the action

public class SameOwnerRequirement : IAuthorizationRequirement { }

public class SameOwnerHandler : AuthorizationHandler<SameOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, SameOwnerRequirement requirement, Order resource)
    {
        if (context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value == resource.OwnerId)
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}
Enter fullscreen mode Exit fullscreen mode

AuthorizationHandler<TRequirement, TResource> (note the second type parameter) is specifically designed for exactly this case — the handler receives not just the requirement, but the actual resource instance to evaluate against, letting the check genuinely depend on the specific data being accessed, not just the caller's static identity.

Invoking a resource-based check: this CANNOT happen via [Authorize] alone — it needs Section 9's imperative check

public async Task<IActionResult> EditOrder(int id, [FromServices] IAuthorizationService authService)
{
    var order = await _orderRepository.GetByIdAsync(id); // load the SPECIFIC resource FIRST
    var authResult = await authService.AuthorizeAsync(User, order, "SameOwnerPolicy"); // THEN check against it
    if (!authResult.Succeeded) return Forbid();

    // proceed with editing the order
}
Enter fullscreen mode Exit fullscreen mode

This is precisely why resource-based authorization requires the imperative IAuthorizationService pattern (Section 9) rather than a declarative attribute — the resource genuinely doesn't exist yet at the point [Authorize] would normally run (before the action, before any data has been loaded), so the check has to happen explicitly, inside the action, after the specific resource has been fetched.


9. Imperative Authorization: IAuthorizationService

The general-purpose service underlying EVERY authorization check this guide covers, including [Authorize] itself

public class OrdersController : ControllerBase
{
    private readonly IAuthorizationService _authorizationService;
    public OrdersController(IAuthorizationService authorizationService) => _authorizationService = authorizationService;

    public async Task<IActionResult> SomeAction()
    {
        var result = await _authorizationService.AuthorizeAsync(User, "SomePolicy");
        if (!result.Succeeded) return Forbid();
        // ... proceed
    }
}
Enter fullscreen mode Exit fullscreen mode

Worth knowing explicitly: [Authorize] attributes, underneath, are themselves ultimately calling into this exact same IAuthorizationService — it's the single, genuine source of truth for every authorization decision in the framework, and injecting it directly gives you full, explicit, imperative control over exactly when and against what a check happens, which is precisely what Section 8's resource-based scenario requires.

AuthorizeAsync overloads: with or without a specific resource

await _authorizationService.AuthorizeAsync(User, "PolicyName"); // no resource — same as a [Authorize(Policy=...)] check
await _authorizationService.AuthorizeAsync(User, order, "SameOwnerPolicy"); // WITH a resource, per Section 8
Enter fullscreen mode Exit fullscreen mode

Both forms exist, and the second is precisely the mechanism Section 8's resource-based flow relies on — passing the loaded resource instance directly into the check, letting whatever AuthorizationHandler<TRequirement, TResource> handlers are registered for the relevant requirement evaluate against it.


10. How Authorization Actually Runs in the Pipeline

UseAuthorization(): the middleware, per this series' Middleware guide's Section 10

app.UseAuthorization(); // consults the MATCHED endpoint's authorization metadata, per this series' Middleware guide
Enter fullscreen mode Exit fullscreen mode

This series' Middleware guide's Section 10 and 11 already establish that UseAuthorization() runs after routing (it needs to know which endpoint was matched, to know what that endpoint's specific authorization requirements are) — worth restating here with this guide's own depth behind it: this middleware is what invokes IAuthorizationService (Section 9) against the endpoint's declared policy/role/claim requirements, and short-circuits with a 401/403 if they aren't met, all before the endpoint itself ever runs.

The authorization FILTER, layered inside the middleware, per this series' Filters guide's Section 3

Per this series' Filters guide: [Authorize] attributes are implemented as
  AUTHORIZATION FILTERS — running WITHIN the MVC action-invocation step,
  which is itself the terminal middleware step UseAuthorization's own
  broader check has already passed by the time filters run for a
  MATCHED MVC action specifically.
Enter fullscreen mode Exit fullscreen mode

Worth reconciling explicitly with this series' Filters guide, since both UseAuthorization() middleware and MVC's own authorization filters are genuinely both part of the same overall picture: the middleware-level check (endpoint-metadata-driven, applying uniformly regardless of MVC vs. minimal APIs) and the MVC-specific authorization filter (running within the filter pipeline this series' Filters guide details) work together — for typical MVC controller actions, [Authorize] attribute metadata is read and enforced by the middleware-level mechanism directly via endpoint metadata, with the filter-based view being the historically earlier mechanism that's now largely unified with it in modern ASP.NET Core.


11. Fallback Policies and Requiring Authorization by Default

The problem: forgetting [Authorize] on a new endpoint means it's open by default

By DEFAULT, an endpoint with NO [Authorize] attribute and NO [AllowAnonymous]
  attribute is ACCESSIBLE ANONYMOUSLY — this is a genuinely easy thing
  to forget on a new controller/action, and the DEFAULT behavior (open
  access) is the opposite of what most security-conscious applications
  actually want as their SAFE default.
Enter fullscreen mode Exit fullscreen mode

This is worth flagging directly as a real, common source of accidental exposure — the framework's default posture is permissive, not restrictive, which means a forgotten [Authorize] attribute silently leaves an endpoint open, rather than silently locking it down.

The fix: a fallback policy requiring authentication (or authorization) for EVERYTHING, by default

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build(); // EVERY endpoint now requires AUTHENTICATION unless explicitly marked [AllowAnonymous]
});
Enter fullscreen mode Exit fullscreen mode

Setting a FallbackPolicy flips the framework's default posture: now every endpoint requires (at minimum) an authenticated caller unless it's explicitly, deliberately marked [AllowAnonymous] — this is widely recommended as a genuinely safer default for most applications, since it converts "forgot to add [Authorize]" from a silent security gap into "nothing happens until you deliberately opt an endpoint out of the requirement," which is a far safer failure mode.


12. Common Pitfalls

Pitfall Why it hurts Better approach
Stacking [Authorize(Roles = "A,B")] expecting AND logic A comma-separated role list within ONE attribute is OR; genuine AND requires stacking separate [Authorize] attributes Understand the OR-within-one-attribute vs. AND-across-stacked-attributes distinction precisely (Section 2)
Calling context.Fail() inside a handler that simply doesn't apply to the current situation Immediately and irreversibly fails the ENTIRE authorization check, overriding every other handler, even ones that would have succeeded Reserve Fail() for genuinely absolute conditions; otherwise just return without calling Succeed() or Fail() (Section 6)
Trying to express resource-specific rules ("can edit their OWN order") via a plain [Authorize(Policy = "...")] attribute Attribute-based checks run before the action, with no access to the specific resource being operated on Use resource-based authorization via IAuthorizationService.AuthorizeAsync(user, resource, policy), checked explicitly inside the action (Section 8)
Scattering the same combination of role/claim checks across many [Authorize] attributes instead of naming a policy A future change to the rule requires finding and updating every scattered occurrence Define the rule once as a named policy; reference it by name everywhere it applies (Section 4-5)
Assuming an endpoint is secure by default The framework's default is permissive — no [Authorize] means open, anonymous access Set a FallbackPolicy requiring authentication by default, opting specific endpoints OUT via [AllowAnonymous] instead (Section 11)
Registering an authorization handler by its CONCRETE type instead of IAuthorizationHandler The authorization system specifically looks up ALL registered IAuthorizationHandler implementations — a concrete-type-only registration won't be discovered Always register custom handlers as IAuthorizationHandler (Section 6)
Putting genuine business logic requiring DI-resolved services into a RequireAssertion lambda RequireAssertion lambdas don't have straightforward access to injected services the way a proper AuthorizationHandler class does Use a full custom IAuthorizationRequirement/AuthorizationHandler pair when the check needs real, injected dependencies (Section 6)
Confusing "multiple handlers for one requirement" (OR) with "multiple requirements on one policy" (AND) Leads to incorrect assumptions about how a complex policy with several moving parts actually evaluates Hold both combination rules distinctly in mind — they answer genuinely different questions (Section 7)

Quick Reference Table

Concept C# Syntax Purpose
Role check [Authorize(Roles = "Admin")] Simplest, coarse-grained authorization based on a role claim
Claim check policy.RequireClaim("Department", "Sales") Checks presence/value of any claim, not just roles
Named policy options.AddPolicy("Name", policy => ...) A reusable, centrally-defined authorization rule
Custom requirement class MyRequirement : IAuthorizationRequirement Data-only description of what's being checked
Custom handler class MyHandler : AuthorizationHandler<MyRequirement> The actual, DI-resolved logic evaluating the requirement
Resource-based check AuthorizationHandler<TRequirement, TResource> Evaluates against a specific loaded resource, not just the caller
Imperative check await authService.AuthorizeAsync(User, resource, "Policy") Explicit, in-code authorization, required for resource-based scenarios
Safe-by-default posture options.FallbackPolicy = ...RequireAuthenticatedUser().Build(); Requires authentication everywhere unless explicitly opted out

Conclusion

Authorization in ASP.NET Core scales deliberately, from the simplest possible check ([Authorize(Roles = "Admin")]) up through named, composable policies, to fully custom requirement/handler pairs capable of evaluating against genuine dependency-injected services and specific, loaded resources — and understanding the combination rules underneath that progression (OR across multiple handlers for one requirement, AND across multiple requirements within one policy) is what makes a complex, real-world authorization scheme something you can actually predict and reason about, rather than something you're testing by trial and error. Resource-based authorization exists specifically because declarative, attribute-based checks structurally cannot know about the specific data an action is about to touch — that gap is real, not a framework oversight, and IAuthorizationService's imperative form is the correct, intended way to close it.

Everything this guide covers exists downstream of, and entirely dependent on, the identity this series' Authentication guide establishes — authorization never re-verifies who someone is; it only ever asks what that already-established someone is allowed to do, and a FallbackPolicy requiring authentication by default is worth treating as close to mandatory in any real application, since the alternative — an endpoint silently left open because an [Authorize] attribute was simply forgotten — is exactly the kind of gap that a safer default, rather than developer vigilance alone, should be closing.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the forgot-the-Authorize-attribute-and-it-was-open-for-weeks incident that made a FallbackPolicy feel less like a nice-to-have and more like a genuine default requirement.

Top comments (0)