Authorization Policies & Claims
Key Points
- Three styles in ASP.NET Core: role-based (
[Authorize(Roles="Admin")]), claims-based ([Authorize(Policy="HasDept")]), policy-based (customIAuthorizationRequirement+AuthorizationHandler). - Policy-based is the senior default. Composable, testable, future-proof. Roles are a thin layer on top of claims.
- Resource-based authorization for "can this user edit this document?" — uses
IAuthorizationService.AuthorizeAsync(user, resource, requirement). - Combine requirements in one policy with
ANDsemantics (all must pass). Use multiple policies on one endpoint when you needOR. FallbackPolicyvsDefaultPolicy: Default = applied when[Authorize]has no policy named. Fallback = applied to unattributed endpoints.- Authorization runs after authentication — by then
Useris populated. Authorization rejects with 401 (no auth) or 403 (auth'd but not allowed).
Concepts (deep dive)
Role-based
Roles are just claims with ClaimTypes.Role. The Roles= argument is a comma-separated OR list.
Limitation: roles are static. Can't express "Manager in Sales department only". For that, use claims or policies.
Claims-based via simple policy
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("HasEmail", p => p.RequireClaim(ClaimTypes.Email));
o.AddPolicy("Over18", p => p.RequireClaim("age", "18", "19", "20", "21" /* ... */));
});
[Authorize(Policy = "HasEmail")]
public IActionResult Profile() => View();
RequireClaim checks for presence (and optionally specific values). Multiple Require* calls on a policy = AND.
Policy-based with custom requirement
For non-trivial logic (DB lookup, time-of-day, computed):
public class MinimumAgeRequirement(int age) : IAuthorizationRequirement
{
public int Age { get; } = age;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx, MinimumAgeRequirement req)
{
var dob = ctx.User.FindFirst("dob")?.Value;
if (DateTime.TryParse(dob, out var d) && (DateTime.UtcNow - d).TotalDays / 365 >= req.Age)
ctx.Succeed(req);
return Task.CompletedTask;
}
}
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
builder.Services.AddAuthorization(o =>
o.AddPolicy("AtLeast18", p => p.AddRequirements(new MinimumAgeRequirement(18))));
[Authorize(Policy = "AtLeast18")]
public IActionResult Adult() => View();
Requirements are data; handlers do the logic. Multiple handlers per requirement can be registered — any one calling Succeed passes.
Resource-based authorization
public class DocumentAuthHandler : AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx,
OperationAuthorizationRequirement req,
Document resource)
{
if (req.Name == "Edit" && resource.OwnerId == ctx.User.FindFirstValue("sub"))
ctx.Succeed(req);
return Task.CompletedTask;
}
}
// In the endpoint:
public async Task<IResult> Edit(Guid id, IAuthorizationService auth, AppDb db)
{
var doc = await db.Documents.FindAsync(id);
var result = await auth.AuthorizeAsync(User, doc, new OperationAuthorizationRequirement { Name = "Edit" });
return result.Succeeded ? Results.Ok(doc) : Results.Forbid();
}
You can't decorate a method with "user owns this entity" via attribute alone — you need the entity at runtime. Resource-based is the answer.
Multiple handlers / OR logic
Two handlers for one requirement, either succeeding = pass:
builder.Services.AddSingleton<IAuthorizationHandler, OwnerHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AdminHandler>();
// Either Owner OR Admin succeeds → policy passes
For endpoint-level OR: define two policies, attach both with explicit naming, and use a custom policy provider — or just create a single combined handler.
Default vs Fallback policy
builder.Services.AddAuthorization(o =>
{
// applies when [Authorize] is used WITHOUT a named policy
o.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
// applies to endpoints WITHOUT [Authorize] AND without [AllowAnonymous]
o.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
FallbackPolicy = RequireAuthenticatedUser() is a great default — secure-by-default. Public endpoints use [AllowAnonymous].
[AllowAnonymous]
Wins over any [Authorize] on the controller. Use sparingly.
Custom policy providers
For dynamic policies (e.g., [Authorize(Policy="Permission:Orders.Read")] where Orders.Read is one of thousands), implement IAuthorizationPolicyProvider:
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
public Task<AuthorizationPolicy?> GetPolicyAsync(string name)
{
if (name.StartsWith("Permission:"))
{
var perm = name["Permission:".Length..];
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionRequirement(perm))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return /* fall back to default */;
}
}
This unlocks attribute-driven fine-grained permissions without hard-coding every policy.
Claims transformation
public class RoleEnricher : IClaimsTransformation
{
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true) return principal;
var identity = (ClaimsIdentity)principal.Identity;
if (!identity.HasClaim(c => c.Type == ClaimTypes.Role))
{
// load roles from DB by user ID
var roles = await _roleService.GetRolesAsync(principal.FindFirstValue("sub"));
foreach (var r in roles) identity.AddClaim(new Claim(ClaimTypes.Role, r));
}
return principal;
}
}
builder.Services.AddScoped<IClaimsTransformation, RoleEnricher>();
Runs per request, after authentication, before authorization. Great for loading role/permission claims that you didn't want in the token.
Beware: runs every request — cache or memoize.
Operation-based vs role-based design
// ❌ Role-based — explodes
[Authorize(Roles = "Admin,Manager,SrManager,Director")] public IActionResult Approve() {}
// ✅ Operation-based — describes intent
[Authorize(Policy = "Permission:Orders.Approve")] public IActionResult Approve() {}
Roles are organizational. Permissions are functional. Map roles → permissions in your IdP or claims transformer; check permissions in code.
[Authorize] on Minimal API
app.MapGet("/admin", () => "ok").RequireAuthorization("AdminPolicy");
// or with raw requirement:
app.MapGet("/age", () => "ok").RequireAuthorization(p => p.AddRequirements(new MinimumAgeRequirement(18)));
Same semantics as MVC [Authorize].
401 vs 403
- 401 Unauthorized — no/invalid credentials. Authentication challenge.
- 403 Forbidden — credentials valid; not allowed. No re-auth helps.
ASP.NET handles this correctly — anonymous failing RequireAuthenticatedUser = 401; authenticated failing a custom policy = 403.
Code: correct vs wrong
❌ Wrong: hardcoded role check
Spread across many actions; brittle.
✅ Correct: policy
builder.Services.AddAuthorization(o =>
o.AddPolicy("CanApprove", p => p.RequireRole("Approver", "Admin")));
[Authorize(Policy = "CanApprove")]
public IActionResult Approve() => /* ... */;
❌ Wrong: resource check via attribute
[Authorize(Policy = "OwnerOnly")] // can't access {id} from attribute
public IActionResult Edit(int id) { /* ... */ }
✅ Correct: resource-based
public async Task<IActionResult> Edit(int id)
{
var doc = await _db.Documents.FindAsync(id);
var auth = await _authorization.AuthorizeAsync(User, doc, "EditDocument");
return auth.Succeeded ? View(doc) : Forbid();
}
❌ Wrong: FallbackPolicy skipped, public-by-default
builder.Services.AddAuthorization(/* nothing */);
// Endpoints without [Authorize] are public — easy to forget [Authorize]
✅ Correct: secure-by-default
builder.Services.AddAuthorization(o =>
o.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
// Public endpoints opt in with [AllowAnonymous]
Design patterns for this topic
Pattern 1 — "Permission-based with policy provider"
- Intent: dynamic per-permission policies; clean attribute use.
Pattern 2 — "Resource-based for entity-level"
- Intent: "user owns this row" decisions.
Pattern 3 — "Claims transformation for permissions"
- Intent: load permissions per request without bloating tokens.
Pattern 4 — "Fallback policy for secure-by-default"
- Intent: prevent accidental public endpoints.
Pattern 5 — "Multiple handlers per requirement (OR)"
- Intent: multiple paths to authorization (owner OR admin).
Pros & cons / trade-offs
| Style | Pros | Cons |
|---|---|---|
| Role-based | Simple | Static; explodes for granular |
| Claims-based simple | Easy | Limited logic |
| Policy-based custom | Powerful | More code |
| Resource-based | Entity-level | Two-step (load + check) |
| Permission-based | Granular; attribute-clean | Requires policy provider |
When to use / when to avoid
- Use policies for any non-trivial check.
- Use resource-based when authorization depends on the entity.
- Use permission-based with policy provider for fine-grained enterprise apps.
- Avoid role checks scattered in code — centralize in policies.
- Avoid bloated JWTs with all permissions — use claims transformation.
Interview Q&A
Q1. Difference between authentication and authorization? AuthN = who you are; AuthZ = what you can do.
Q2. Role-based vs claims-based? Roles are claims with ClaimTypes.Role. Roles are coarse; claims are arbitrary. Policies wrap both.
Q3. What's a policy? A named set of IAuthorizationRequirements. Endpoints reference policy by name; runtime evaluates requirements via handlers.
Q4. Multiple handlers per requirement — AND or OR? OR — any handler succeeding satisfies the requirement. Multiple requirements within a policy is AND.
Q5. Resource-based authorization? IAuthorizationService.AuthorizeAsync(user, resource, req) — load the entity, then check. For "user owns this row" decisions.
Q6. What's FallbackPolicy? Applied to endpoints without [Authorize] and without [AllowAnonymous]. Setting to RequireAuthenticatedUser makes the API secure-by-default.
Q7. How implement permission-based authorization? Custom IAuthorizationPolicyProvider that creates policies on-the-fly from permission names, plus a handler that checks user's permission claims.
Q8. Where should permissions live? For small sets, in JWT claims. For large or volatile sets, in a database — load via claims transformation per request (with caching).
Q9. 401 vs 403? 401 = unauthenticated. 403 = authenticated but not authorized.
Q10. What's claims transformation? IClaimsTransformation runs after authentication, before authorization. Enriches the principal — e.g., load roles from DB.
Q11. Why prefer policies over User.IsInRole(...)? Centralizes logic, easier to test, easier to change without touching every endpoint, supports composition.
Q12. Pitfall of dynamically loading permissions every request? Performance. Cache the per-user permission set with sensible invalidation.
Gotchas / common mistakes
- ⚠️ Default-public endpoints — set
FallbackPolicy. - ⚠️ Role-only auth on a granular system — design pain.
- ⚠️ Duplicating policies in attribute strings — typos slip through.
- ⚠️ Heavy DB calls in claims transformation without cache.
- ⚠️ Policy with multiple
Require*without realizing it's AND.