| 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
- Policy-Based Over Role-Based โ Use authorization policies instead of
[Authorize(Roles = "...")]. Policies are composable, testable, and decoupled from claim types.
- 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.
- Authorization is Separate from Authentication โ Authentication (see
aspnetcore-authentication) establishes identity. Authorization decides access based on that identity.
- Fail Closed โ Default to denying access. Require explicit authorization on all endpoints.
- 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/tokens/authorization
Pattern 1: Basic Policy-Based Authorization
Define policies at startup and reference them on endpoints:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.AddPolicy("read:catalog", policy =>
policy.RequireClaim("scope", "catalog.read"));
options.AddPolicy("admin", policy =>
policy.RequireRole("admin"));
options.AddPolicy("catalog-editor", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "catalog.write");
policy.RequireClaim("department", "merchandising");
});
});
Applying Policies
app.MapGet("/products", () => Results.Ok())
.RequireAuthorization("read:catalog");
[Authorize(Policy = "catalog-editor")]
public class CatalogController : ControllerBase { }
[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
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:
public class ScopeRequirement : IAuthorizationRequirement
{
public string Scope { get; }
public ScopeRequirement(string scope) => Scope = scope;
}
public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
var scopeClaim = context.User.FindFirst("scope");
if (scopeClaim is null)
{
return Task.CompletedTask;
}
var scopes = scopeClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (scopes.Contains(requirement.Scope))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
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:
public class MinimumTenureRequirement : IAuthorizationRequirement
{
public int MinimumYears { get; }
public MinimumTenureRequirement(int years) => MinimumYears = years;
}
public class MinimumTenureHandler : AuthorizationHandler<MinimumTenureRequirement>
{
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;
}
}
builder.Services.AddSingleton<IAuthorizationHandler, MinimumTenureHandler>();
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):
public class AdminByRoleHandler : AuthorizationHandler<AdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, AdminRequirement requirement)
{
if (context.User.IsInRole("admin"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class AdminByDepartmentHandler : AuthorizationHandler<AdminRequirement>
{
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:
public class DocumentAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Document resource)
{
var userId = context.User.FindFirst("sub")?.Value;
if (requirement == Operations.Read)
{
if (context.User.HasClaim("department", resource.Department))
context.Succeed(requirement);
}
else if (requirement == Operations.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
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<IActionResult> 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:
app.MapGet("/public", () => "Anyone can see this")
.AllowAnonymous();
app.MapGet("/products", GetProducts)
.RequireAuthorization("read:catalog");
app.MapPost("/products", CreateProduct)
.RequireAuthorization("catalog-editor");
app.MapDelete("/products/{id}", DeleteProduct)
.RequireAuthorization(policy =>
policy.RequireClaim("scope", "catalog.write")
.RequireRole("admin"));
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:
public class ApiWriteRequirement : IAuthorizationRequirement { }
public class ApiWriteHandler : AuthorizationHandler<ApiWriteRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ApiWriteRequirement requirement)
{
var hasScope = context.User.HasClaim(c =>
c.Type == "scope" && c.Value.Split(' ').Contains("catalog.write"));
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
builder.Services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
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
[Authorize(Roles = "admin,superadmin,it-ops")]
public IActionResult Dashboard() { }
options.AddPolicy("dashboard-access", policy =>
policy.RequireRole("admin", "superadmin", "it-ops"));
[Authorize(Policy = "dashboard-access")]
public IActionResult Dashboard() { }
2. Not Registering Authorization Handlers
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
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.
protected override Task HandleRequirementAsync(...)
{
if (!context.User.HasClaim("scope", "api.read"))
context.Fail();
return Task.CompletedTask;
}
protected override Task HandleRequirementAsync(...)
{
if (context.User.HasClaim("scope", "api.read"))
context.Succeed(requirement);
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
options.AddPolicy("write", p => p.RequireRole("editor"));
options.AddPolicy("write", p =>
{
p.RequireClaim("scope", "catalog.write");
p.RequireRole("editor");
});
Resources