Role-based authorization checks whether the user belongs to a named role — it's simple but inflexible. Policy-based authorization evaluates one or more composable requirements, enabling richer rules (minimum age, department, subscription tier, etc.).
// Role-based — check a single ClaimTypes.Role claim:
[Authorize(Roles = "Admin")] // single role
[Authorize(Roles = "Admin,Manager")] // either role (OR logic)
public class AdminController : ControllerBase { }
// Stacking [Authorize] means AND — both must pass:
[Authorize(Roles = "Admin")]
[Authorize(Roles = "Manager")]
public class AdminManagerOnlyController : ControllerBase { }
// Policy-based — named policy with composable requirements:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("SeniorEmployee", policy =>
policy.RequireAuthenticatedUser()
.RequireRole("Employee")
.RequireClaim("department", "Engineering", "Product")
.RequireClaim("years_tenure")
.AddRequirements(new MinimumTenureRequirement(years: 3)));
[Authorize(Policy = "SeniorEmployee")]
public class SeniorPortalController : ControllerBase { }
// Policy centralizes rules — change the policy definition, all usages update:
// Role check is just a shortcut policy under the hood.
Rule of thumb: Use Roles for simple, coarse-grained access. Use policies for
anything beyond a single role check — they're reusable, testable, and centralized.
An IAuthorizationRequirement is a data object that describes what must be true.
An IAuthorizationHandler evaluates whether the requirement is met for a given
user and resource.
// 1. Define the requirement (plain data, no logic):
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
// 2. Implement the handler (all logic lives here):
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dob = context.User.FindFirstValue(ClaimTypes.DateOfBirth);
if (dob is null)
{
// Don't call Fail() — let other handlers try if any are registered:
return Task.CompletedTask;
}
var age = DateTime.Today.Year - DateTime.Parse(dob).Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement); // requirement satisfied — call this to pass
else
context.Fail(); // explicitly deny (overrides any Succeed)
return Task.CompletedTask;
}
}
// 3. Register both:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AtLeast18", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
// One requirement can have multiple handlers — any Succeed passes the requirement:
// All requirements in a policy must pass for the policy to succeed.
Rule of thumb: Keep requirements as pure data (no logic). All evaluation logic
belongs in the handler. This separation makes handlers independently testable with
just a constructed AuthorizationHandlerContext.
Resource-based authorization evaluates permissions against a specific resource
instance — "can this user edit this document?" — as opposed to global role checks.
It uses IAuthorizationService directly in code because the resource isn't known at
route decoration time.
// Requirement — carries the operation:
public static class DocumentOperations
{
public static OperationAuthorizationRequirement Read = new() { Name = "Read" };
public static OperationAuthorizationRequirement Edit = new() { Name = "Edit" };
public static OperationAuthorizationRequirement Delete = new() { Name = "Delete" };
}
// Handler — receives the resource instance at runtime:
public class DocumentAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Document document) // ← the actual resource instance
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (requirement.Name == "Read" && document.IsPublic)
context.Succeed(requirement);
else if (document.OwnerId == userId)
context.Succeed(requirement); // owner can do anything
else if (requirement.Name != "Delete" && context.User.IsInRole("Editor"))
context.Succeed(requirement); // editors can read/edit but not delete
return Task.CompletedTask;
}
}
// Register:
builder.Services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
// Call from a controller or service — inject IAuthorizationService:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateDocument(int id, [FromBody] UpdateDto dto)
{
var document = await _repo.GetByIdAsync(id);
if (document is null) return NotFound();
var authResult = await _authorizationService.AuthorizeAsync(
User, document, DocumentOperations.Edit);
if (!authResult.Succeeded) return Forbid();
await _repo.UpdateAsync(document, dto);
return NoContent();
}
Rule of thumb: Use [Authorize(Policy = "...")] for global checks (role, age).
Use IAuthorizationService.AuthorizeAsync(user, resource, requirement) when the
decision depends on the data being accessed.
RequireClaim in a policy checks for the presence of a claim type, or optionally
validates that its value is in an allowed set.
builder.Services.AddAuthorizationBuilder()
// Require the claim to exist (any value):
.AddPolicy("VerifiedEmail", policy =>
policy.RequireClaim(ClaimTypes.Email))
// Require specific values (OR — any value in the set passes):
.AddPolicy("EURegion", policy =>
policy.RequireClaim("region", "EU", "EEA", "CH"))
// Combine multiple claim requirements (AND):
.AddPolicy("VerifiedEUUser", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim(ClaimTypes.Email)
.RequireClaim("email_verified", "true")
.RequireClaim("region", "EU", "EEA"))
// Custom requirement for complex value logic:
.AddPolicy("PremiumOrTrial", policy =>
policy.RequireAssertion(ctx =>
{
var sub = ctx.User.FindFirstValue("subscription");
var trial = ctx.User.FindFirstValue("trial_active");
return sub == "premium" || trial == "true";
}));
// RequireAssertion — inline lambda for simple one-off logic:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("WeekdayOnly", policy =>
policy.RequireAssertion(_ =>
DateTime.UtcNow.DayOfWeek is not DayOfWeek.Saturday
and not DayOfWeek.Sunday));
Rule of thumb: Use RequireClaim for attribute-style checks that map neatly to a
claim value. Use RequireAssertion for inline logic that doesn't warrant a full
requirement+handler pair. Move complex, reusable logic to a proper handler.
The default policy is applied when [Authorize] is used with no policy name.
The fallback policy is applied to every endpoint that has no authorization
metadata at all — including endpoints that never have [Authorize] on them.
builder.Services.AddAuthorizationBuilder()
// Applied when [Authorize] has no parameters:
.SetDefaultPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build())
// Applied to ALL endpoints with no [Authorize] attribute
// (opt-in secure-by-default):
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
// With a fallback policy, every route is protected unless explicitly opened:
// app.MapGet("/public", ...).AllowAnonymous();
// Secure-by-default pattern with minimal API groups:
var secured = app.MapGroup("/api").RequireAuthorization(); // all children protected
var open = app.MapGroup("/public").AllowAnonymous(); // all children public
// Disable fallback for a specific endpoint:
secured.MapGet("/health", () => "ok").AllowAnonymous();
// Without a fallback policy (default behavior) — anonymous access is allowed
// to any route without [Authorize]. Easy to accidentally expose sensitive routes.
Rule of thumb: Set a fallback policy of RequireAuthenticatedUser in API-only
applications — it makes the secure path the default and forces you to explicitly
opt out with [AllowAnonymous]. This prevents accidentally exposing endpoints.
[AllowAnonymous] bypasses all authorization requirements for the endpoint it decorates,
including the fallback policy. It's absolute — no policy can override it.
// [AllowAnonymous] at action level overrides [Authorize] at controller level:
[Authorize]
public class AccountController : ControllerBase
{
[AllowAnonymous] // no auth needed — this action is public
[HttpGet("login")]
public IActionResult Login() => View();
[AllowAnonymous] // public — return form to browser
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDto dto) { /* ... */ return Ok(); }
[HttpGet("profile")] // inherits [Authorize] from controller — requires auth
public IActionResult Profile() => Ok(User.Identity!.Name);
[Authorize(Roles = "Admin")] // requires auth AND Admin role
[HttpGet("admin")]
public IActionResult Admin() => Ok();
}
// In minimal APIs:
var api = app.MapGroup("/api").RequireAuthorization();
api.MapGet("/products", (IProductService svc) => svc.GetAll()); // protected
api.MapGet("/health", () => Results.Ok()).AllowAnonymous(); // public
// [AllowAnonymous] defeats even a fallback policy:
// If SetFallbackPolicy(requireAuth), AllowAnonymous still bypasses it.
Rule of thumb: Put [Authorize] at the highest scope (controller, group) and
[AllowAnonymous] at the lowest scope (individual actions/endpoints). Never put
[AllowAnonymous] at the controller level — it becomes invisible to callers adding
new protected actions.
IAuthorizationPolicyProvider is called by the authorization system to look up
policies by name. The default implementation searches the policies registered in
AddAuthorization. A custom provider lets you generate policies dynamically — useful
for parameterized policies like MinimumAge:18 without registering every variant
upfront.
// Without a custom provider, every variant must be registered explicitly:
// .AddPolicy("MinimumAge:16", ...) .AddPolicy("MinimumAge:18", ...) — tedious
// With a custom provider — generate policies on demand:
public class MinimumAgePolicyProvider : IAuthorizationPolicyProvider
{
private const string PolicyPrefix = "MinimumAge:";
private readonly DefaultAuthorizationPolicyProvider _fallback;
public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(policyName[PolicyPrefix.Length..], out var age))
{
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new MinimumAgeRequirement(age))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
return _fallback.GetPolicyAsync(policyName); // delegate to default
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> _fallback.GetFallbackPolicyAsync();
}
// Register (replaces the default provider):
builder.Services.AddSingleton<IAuthorizationPolicyProvider, MinimumAgePolicyProvider>();
// Use parameterized policy names anywhere:
[Authorize(Policy = "MinimumAge:21")]
public IActionResult AlcoholSection() => Ok();
[Authorize(Policy = "MinimumAge:16")]
public IActionResult TeenSection() => Ok();
Rule of thumb: Implement IAuthorizationPolicyProvider only when you have a family
of policies that differ only by a parameter (age, permission ID, etc.). Always fall back
to DefaultAuthorizationPolicyProvider for all other policy names.
Roles are coarse-grained. For fine-grained control, model permissions as claims or a custom requirement, and load them from your data store during authentication.
// Option 1 — permission claims added at login time:
// Permissions are stored in the DB (per-user or per-role) and embedded in the token.
public class PermissionService
{
private readonly AppDbContext _db;
public PermissionService(AppDbContext db) => _db = db;
public async Task<IEnumerable<string>> GetForUserAsync(string userId)
=> await _db.UserPermissions
.Where(p => p.UserId == userId)
.Select(p => p.Name) // e.g. "orders:read", "orders:write"
.ToListAsync();
}
// Embed permissions as claims when the cookie/token is created:
var permissions = await _permissionService.GetForUserAsync(user.Id);
var claims = permissions.Select(p => new Claim("permission", p)).ToList();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id));
// Option 2 — custom requirement + handler that checks the claim:
public class PermissionRequirement : IAuthorizationRequirement
{
public string Permission { get; }
public PermissionRequirement(string permission) => Permission = permission;
}
public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var hasPermission = context.User
.FindAll("permission")
.Any(c => c.Value == requirement.Permission);
if (hasPermission)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Register and define policies:
builder.Services.AddSingleton<IAuthorizationHandler, PermissionHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("orders:read", p => p.AddRequirements(new PermissionRequirement("orders:read")))
.AddPolicy("orders:write", p => p.AddRequirements(new PermissionRequirement("orders:write")));
// Use on endpoints:
[Authorize(Policy = "orders:write")]
[HttpPost("orders")]
public IActionResult CreateOrder([FromBody] CreateOrderDto dto) => Ok();
Rule of thumb: Keep permissions granular and name them as resource:action strings
(e.g., orders:write). Store them per-role in the DB so you can update permissions
without redeploying; embed them in the token at login to avoid a DB lookup on every request.
Use RequireAssertion for simple inline rules, or implement a full
IAuthorizationRequirement + handler for complex, reusable conditions. The
AuthorizationHandlerContext gives you access to HttpContext via the resource.
// Simple time-of-day gate via RequireAssertion:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("BusinessHoursOnly", policy =>
policy.RequireAuthenticatedUser()
.RequireAssertion(_ =>
{
var now = DateTime.UtcNow;
// Allow Mon–Fri 09:00–17:00 UTC:
return now.DayOfWeek is not DayOfWeek.Saturday
and not DayOfWeek.Sunday
&& now.Hour >= 9 && now.Hour < 17;
}));
// IP range restriction — requirement + handler accessing HttpContext:
public class AllowedIpRequirement : IAuthorizationRequirement
{
public IReadOnlyList<string> AllowedPrefixes { get; }
public AllowedIpRequirement(params string[] prefixes) => AllowedPrefixes = prefixes;
}
public class AllowedIpHandler : AuthorizationHandler<AllowedIpRequirement>
{
private readonly IHttpContextAccessor _http;
public AllowedIpHandler(IHttpContextAccessor http) => _http = http;
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AllowedIpRequirement requirement)
{
var remoteIp = _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
if (remoteIp is not null &&
requirement.AllowedPrefixes.Any(prefix => remoteIp.StartsWith(prefix)))
{
context.Succeed(requirement);
}
// Note: not calling Fail() allows other handlers to decide if registered
return Task.CompletedTask;
}
}
// Register and apply:
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IAuthorizationHandler, AllowedIpHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("InternalNetworkOnly", policy =>
policy.RequireAuthenticatedUser()
.AddRequirements(new AllowedIpRequirement("10.0.", "192.168.")));
[Authorize(Policy = "InternalNetworkOnly")]
[HttpGet("admin/diagnostics")]
public IActionResult Diagnostics() => Ok("internal only");
Rule of thumb: Prefer RequireAssertion for stateless, self-contained conditions.
Use a proper handler when the condition needs injected services (databases, HTTP context,
configuration) — it keeps the logic testable and reusable across policies.
More Security interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.