--- name: aspnetcore-authorization description: ASP.NET Core authorization patterns including policy-based authorization, IAuthorizationHandler implementations, scope-based authorization for APIs, authorization middleware configuration, and minimal API authorization. invocable: false --- # ASP.NET Core Authorization ## When to Use This Skill Use this skill when: - Implementing policy-based authorization in ASP.NET Core - Protecting API endpoints with scope-based or claim-based checks - Writing custom `IAuthorizationHandler` implementations - Configuring authorization for Minimal APIs, controllers, or Razor Pages - Enforcing role-based access control using OIDC claims - Combining multiple authorization requirements into composite policies ## Core Principles 1. **Policy-Based Over Role-Based** — Use authorization policies instead of `[Authorize(Roles = "...")]`. Policies are composable, testable, and decoupled from claim types. 2. **Scope ≠ Permission** — OAuth scopes represent what the *client* is allowed to do. User claims represent what the *user* is allowed to do. Combine both for proper API authorization. 3. **Authorization is Separate from Authentication** — Authentication (see `aspnetcore-authentication`) establishes identity. Authorization decides access based on that identity. 4. **Fail Closed** — Default to denying access. Require explicit authorization on all endpoints. 5. **Resource-Based When Needed** — For decisions that depend on the resource being accessed (e.g., "can this user edit this document?"), use `IAuthorizationService` with resource-based authorization. ## Related Skills - `aspnetcore-authentication` — Authentication middleware that provides the identity - `claims-authorization` — Advanced claims transformation and authorization patterns - `identityserver-configuration` — Server-side scope and resource configuration - `oauth-oidc-protocols` — Understanding scopes, claims, and token contents Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/ --- ## Pattern 1: Basic Policy-Based Authorization Define policies at startup and reference them on endpoints: ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthorization(options => { // Policy that requires the user to be authenticated options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); // Policy requiring a specific scope in the access token options.AddPolicy("read:catalog", policy => policy.RequireClaim("scope", "catalog.read")); // Policy requiring a specific role options.AddPolicy("admin", policy => policy.RequireRole("admin")); // Policy combining multiple requirements options.AddPolicy("catalog-editor", policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim("scope", "catalog.write"); policy.RequireClaim("department", "merchandising"); }); }); ``` ### Applying Policies ```csharp // Minimal API app.MapGet("/products", () => Results.Ok()) .RequireAuthorization("read:catalog"); // Controller [Authorize(Policy = "catalog-editor")] public class CatalogController : ControllerBase { } // Razor Page [Authorize(Policy = "admin")] public class AdminModel : PageModel { } ``` --- ## Pattern 2: Scope-Based Authorization for APIs APIs protected by IdentityServer need to validate scopes from the access token. Scopes represent what the *client application* is permitted to do. ### Simple Scope Check ```csharp builder.Services.AddAuthorization(options => { options.AddPolicy("api.read", policy => policy.RequireClaim("scope", "catalog.read")); options.AddPolicy("api.write", policy => policy.RequireClaim("scope", "catalog.write")); }); app.MapGet("/products", GetProducts).RequireAuthorization("api.read"); app.MapPost("/products", CreateProduct).RequireAuthorization("api.write"); ``` ### Scope as Space-Delimited String When `EmitScopesAsSpaceDelimitedStringInJwt = true` on IdentityServer, scopes arrive as a single space-delimited string rather than an array. Use a custom handler: ```csharp public class ScopeRequirement : IAuthorizationRequirement { public string Scope { get; } public ScopeRequirement(string scope) => Scope = scope; } public class ScopeHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ScopeRequirement requirement) { var scopeClaim = context.User.FindFirst("scope"); if (scopeClaim is null) { return Task.CompletedTask; // Not handled = denied } // Handle both array claims and space-delimited string var scopes = scopeClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (scopes.Contains(requirement.Scope)) { context.Succeed(requirement); } return Task.CompletedTask; } } // Registration builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => { options.AddPolicy("catalog.read", policy => policy.Requirements.Add(new ScopeRequirement("catalog.read"))); }); ``` --- ## Pattern 3: Custom Authorization Handlers For complex authorization logic, implement `IAuthorizationHandler`: ```csharp // Requirement — what needs to be satisfied public class MinimumTenureRequirement : IAuthorizationRequirement { public int MinimumYears { get; } public MinimumTenureRequirement(int years) => MinimumYears = years; } // Handler — how to evaluate the requirement public class MinimumTenureHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, MinimumTenureRequirement requirement) { var hireDateClaim = context.User.FindFirst("hire_date"); if (hireDateClaim is null) { return Task.CompletedTask; } if (DateTimeOffset.TryParse(hireDateClaim.Value, out var hireDate)) { var tenure = DateTimeOffset.UtcNow - hireDate; if (tenure.TotalDays >= requirement.MinimumYears * 365.25) { context.Succeed(requirement); } } return Task.CompletedTask; } } // Registration builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => { options.AddPolicy("senior-staff", policy => policy.Requirements.Add(new MinimumTenureRequirement(5))); }); ``` ### Multiple Handlers for One Requirement When any handler succeeding should grant access (OR logic): ```csharp // Both handlers evaluate the same requirement // If EITHER succeeds, the requirement is satisfied public class AdminByRoleHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, AdminRequirement requirement) { if (context.User.IsInRole("admin")) context.Succeed(requirement); return Task.CompletedTask; } } public class AdminByDepartmentHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, AdminRequirement requirement) { if (context.User.HasClaim("department", "it-operations")) context.Succeed(requirement); return Task.CompletedTask; } } ``` > **Key concept:** Multiple requirements in a policy use AND logic (all must be satisfied). Multiple handlers for the same requirement use OR logic (any can satisfy it). --- ## Pattern 4: Resource-Based Authorization When authorization depends on the resource being accessed, use `IAuthorizationService`: ```csharp public class DocumentAuthorizationHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, Document resource) { var userId = context.User.FindFirst("sub")?.Value; if (requirement == Operations.Read) { // Anyone in the same department can read if (context.User.HasClaim("department", resource.Department)) context.Succeed(requirement); } else if (requirement == Operations.Edit) { // Only the owner can edit if (resource.OwnerId == userId) context.Succeed(requirement); } return Task.CompletedTask; } } public static class Operations { public static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) }; public static readonly OperationAuthorizationRequirement Edit = new() { Name = nameof(Edit) }; } ``` ### Using in a Controller ```csharp public class DocumentsController : ControllerBase { private readonly IAuthorizationService _authz; private readonly IDocumentRepository _docs; public DocumentsController(IAuthorizationService authz, IDocumentRepository docs) { _authz = authz; _docs = docs; } [HttpGet("{id}")] public async Task Get(string id) { var document = await _docs.GetAsync(id); if (document is null) return NotFound(); var result = await _authz.AuthorizeAsync( User, document, Operations.Read); if (!result.Succeeded) return Forbid(); return Ok(document); } } ``` --- ## Pattern 5: Minimal API Authorization Minimal APIs use the same authorization system with a fluent API: ```csharp // Require authentication on all endpoints by default app.MapGet("/public", () => "Anyone can see this") .AllowAnonymous(); app.MapGet("/products", GetProducts) .RequireAuthorization("read:catalog"); app.MapPost("/products", CreateProduct) .RequireAuthorization("catalog-editor"); // Inline policy app.MapDelete("/products/{id}", DeleteProduct) .RequireAuthorization(policy => policy.RequireClaim("scope", "catalog.write") .RequireRole("admin")); // Group-level authorization var adminGroup = app.MapGroup("/admin") .RequireAuthorization("admin"); adminGroup.MapGet("/users", GetUsers); adminGroup.MapPost("/users", CreateUser); ``` --- ## Pattern 6: Combining Client Scope + User Claims In APIs protected by IdentityServer, proper authorization often requires checking *both* the client's scope and the user's claims: ```csharp public class ApiWriteRequirement : IAuthorizationRequirement { } public class ApiWriteHandler : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ApiWriteRequirement requirement) { // Check 1: Client must have the write scope var hasScope = context.User.HasClaim(c => c.Type == "scope" && c.Value.Split(' ').Contains("catalog.write")); // Check 2: User must be in the editor role var isEditor = context.User.IsInRole("editor"); if (hasScope && isEditor) { context.Succeed(requirement); } return Task.CompletedTask; } } ``` > **Why both?** A malicious client could request broad scopes, but the user may not have permission. A privileged user operating through a restricted client should be limited by that client's scopes. --- ## Pattern 7: Fallback and Default Policies ```csharp builder.Services.AddAuthorization(options => { // DefaultPolicy: applied when [Authorize] has no policy name options.DefaultPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); // FallbackPolicy: applied to endpoints with NO [Authorize] attribute // Setting this makes all endpoints require authentication by default options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); }); ``` | Policy | Applied When | Use Case | |--------|-------------|----------| | `DefaultPolicy` | `[Authorize]` with no policy name | Basic "must be logged in" check | | `FallbackPolicy` | Endpoints with no `[Authorize]` attribute | Secure-by-default for APIs | > **Tip:** Set `FallbackPolicy` to require authentication, then use `[AllowAnonymous]` only on endpoints that genuinely need it (health checks, public assets). --- ## Common Pitfalls ### 1. Using Role Strings Instead of Policies ```csharp // ❌ WRONG — Hardcoded role strings scattered across controllers [Authorize(Roles = "admin,superadmin,it-ops")] public IActionResult Dashboard() { } // ✅ CORRECT — Centralized policy options.AddPolicy("dashboard-access", policy => policy.RequireRole("admin", "superadmin", "it-ops")); [Authorize(Policy = "dashboard-access")] public IActionResult Dashboard() { } ``` ### 2. Not Registering Authorization Handlers ```csharp // ❌ WRONG — Handler exists but never registered // Policy silently denies because no handler evaluates the requirement // ✅ CORRECT — Register the handler in DI builder.Services.AddSingleton(); ``` ### 3. Calling context.Fail() in Handlers `context.Fail()` **actively denies** authorization regardless of what other handlers say — it's a hard veto. Not calling `context.Succeed()` simply means "I have no opinion"; other handlers can still satisfy the requirement. ```csharp // ❌ WRONG — Fail() is a hard veto: it denies even if another handler would succeed protected override Task HandleRequirementAsync(...) { if (!context.User.HasClaim("scope", "api.read")) context.Fail(); // Forces denial — blocks all other handlers permanently! return Task.CompletedTask; } // ✅ CORRECT — Simply don't call Succeed(); let other handlers try protected override Task HandleRequirementAsync(...) { if (context.User.HasClaim("scope", "api.read")) context.Succeed(requirement); // Not calling Succeed() means "I don't know" — other handlers may still succeed return Task.CompletedTask; } ``` > Only call `context.Fail()` when you need to **guarantee** denial even if other handlers would approve (e.g., a security blocklist check). In most cases, simply omit the `Succeed()` call. ### 4. Ignoring Client vs User Authorization ```csharp // ❌ WRONG — Only checking user role, ignoring client scope options.AddPolicy("write", p => p.RequireRole("editor")); // A client without the write scope could still pass this check // ✅ CORRECT — Check both scope and user claims options.AddPolicy("write", p => { p.RequireClaim("scope", "catalog.write"); // Client permission p.RequireRole("editor"); // User permission }); ``` --- ## Resources - [Authorization in ASP.NET Core — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/introduction) - [Policy-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies) - [Resource-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/resourcebased) - [Protecting APIs — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/) - [API Authorization — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/)