Why authorization knowledge matters in .NET interviews
Interviewers frequently distinguish candidates by whether they understand just [Authorize(Roles = "Admin")] or whether they can explain policies, requirements, handlers, and resource-based
authorization. This article covers the authorization model from simple role checks through to
dynamic policy providers.
Role-based vs policy-based authorization
Role-based authorization is a single claim check — is the user's ClaimTypes.Role claim in
the allowed set? It's simple and readable but becomes unmanageable as permission logic grows.
Policy-based authorization composes any number of requirements into a named, reusable rule that you define once and apply anywhere.
// Role-based — simple but scattered, hard to change later:
[Authorize(Roles = "Admin")]
[Authorize(Roles = "Manager")] // stacked = AND
public class ManagerAdminController : ControllerBase { }
// Policy-based — centralized, composable, testable:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("SeniorEngineer", policy =>
policy.RequireAuthenticatedUser()
.RequireRole("Employee")
.RequireClaim("department", "Engineering")
.AddRequirements(new MinimumTenureRequirement(years: 3)));
[Authorize(Policy = "SeniorEngineer")]
public class SeniorPortalController : ControllerBase { }
Changing who qualifies as a "SeniorEngineer" means changing the policy definition in one place —
not hunting through every [Authorize] attribute in the codebase.
Configuring policies
Use AddAuthorizationBuilder (.NET 8+) or AddAuthorization:
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AtLeast18", policy => policy.Requirements.Add(new MinimumAgeRequirement(18)))
.AddPolicy("PremiumUser", policy => policy.RequireAuthenticatedUser()
.RequireClaim("subscription", "premium", "enterprise"))
.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"))
// Default policy — applied when [Authorize] has no parameters:
.SetDefaultPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
// Fallback policy — applied to every endpoint with NO authorization metadata:
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
With a fallback policy, every route is protected unless you explicitly add .AllowAnonymous().
This is the "secure by default" pattern — easier than hoping developers remember [Authorize].
Requirements and handlers — separation of concerns
An IAuthorizationRequirement is a plain data object describing what must be true. An
IAuthorizationHandler contains the evaluation logic. Separating them makes handlers independently
testable.
// Requirement — data only:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int age) => MinimumAge = age;
}
// Handler — all logic 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)
return Task.CompletedTask; // don't Fail — another handler might satisfy this
var age = DateTime.Today.Year - DateTime.Parse(dob).Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
else
context.Fail(); // explicit deny
return Task.CompletedTask;
}
}
// Register:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
Key rules:
- Call
context.Succeed(requirement)to mark the requirement as satisfied. - Call
context.Fail()to explicitly deny — overrides any otherSucceedcall. - Return
Task.CompletedTaskwithout calling either when the handler doesn't apply (gives other handlers a chance). - One requirement can have multiple handlers — any
Succeedpasses the requirement. - All requirements in a policy must be satisfied for the policy to pass.
Resource-based authorization
Sometimes the authorization decision depends on the resource being accessed — "can this user
edit this document?" The resource isn't known at decoration time, so [Authorize] alone
can't express it. Use IAuthorizationService in the controller or service:
// Operation-based requirement:
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:
public class DocumentAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Document document)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (document.IsPublic && requirement.Name == "Read")
context.Succeed(requirement);
else if (document.OwnerId == userId)
context.Succeed(requirement);
else if (requirement.Name != "Delete" && context.User.IsInRole("Editor"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
builder.Services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
// Controller — 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: return NotFound() before Forbid() when the resource doesn't exist — leaking whether
a resource exists can be an information-disclosure vulnerability.
The Authorize attribute in depth
// Bare — uses the default policy (requires IsAuthenticated):
[Authorize]
public IActionResult Profile() => Ok(User.Identity!.Name);
// Role OR logic (comma-separated within one attribute):
[Authorize(Roles = "Admin,SuperAdmin")]
// Role AND logic (stacked attributes — both must pass):
[Authorize(Roles = "Admin")]
[Authorize(Roles = "Manager")]
// Named policy:
[Authorize(Policy = "PremiumUser")]
// Specific authentication scheme:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
// Controller-level + per-action override:
[Authorize]
public class AccountController : ControllerBase
{
[AllowAnonymous] // overrides controller-level auth — open to everyone
public IActionResult Login() => Ok();
[Authorize(Roles = "Admin")] // requires auth + Admin role
public IActionResult Admin() => Ok();
}
Best practice: apply [Authorize] at the controller level and use [AllowAnonymous] on
exceptions. The inverse (controller [AllowAnonymous], action [Authorize]) makes it easy to
accidentally expose an action by forgetting the attribute.
Claims-based authorization
builder.Services.AddAuthorizationBuilder()
// Require the claim to exist with any value:
.AddPolicy("VerifiedEmail", policy => policy.RequireClaim(ClaimTypes.Email))
// Require the claim to have one of the listed values (OR):
.AddPolicy("EURegion", policy => policy.RequireClaim("region", "EU", "EEA", "CH"))
// Inline lambda for one-off 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";
}));
Use RequireClaim for attribute-style checks. Use RequireAssertion for simple inline logic that
doesn't justify a full requirement+handler pair. Move complex, reusable logic into proper handlers.
Default and fallback policies
| Policy | When applied |
|---|---|
| Default policy | When [Authorize] is used with no parameters |
| Fallback policy | For every endpoint with no authorization metadata |
// Secure-by-default: protect everything; opt out with AllowAnonymous:
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
app.MapGet("/products", (IProductService svc) => svc.GetAll()); // protected by fallback
app.MapGet("/health", () => Results.Ok()).AllowAnonymous(); // explicitly public
Global authorization filter
For controller-based apps that don't use a fallback policy:
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
The fallback policy approach is preferred because it covers both controllers and minimal API endpoints with one line.
IAuthorizationService — imperative checks
// Inject into controllers or services:
public class ReportController : ControllerBase
{
private readonly IAuthorizationService _authz;
public ReportController(IAuthorizationService authz) => _authz = authz;
[HttpGet("{id}")]
public async Task<IActionResult> GetReport(int id)
{
var report = await _reportRepo.GetAsync(id);
if (report is null) return NotFound();
// Resource-based check — policy name:
var result = await _authz.AuthorizeAsync(User, report, "CanViewReports");
if (!result.Succeeded) return Forbid();
return Ok(report);
}
}
Use [Authorize(Policy = "...")] for static checks. Use IAuthorizationService.AuthorizeAsync
when the check depends on the resource instance.
Dynamic policies with IAuthorizationPolicyProvider
When you have a family of policies that vary only by parameter (e.g., MinimumAge:18,
MinimumAge:21), implement IAuthorizationPolicyProvider to generate them on demand:
public class MinimumAgePolicyProvider : IAuthorizationPolicyProvider
{
private const string Prefix = "MinimumAge:";
private readonly DefaultAuthorizationPolicyProvider _fallback;
public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)
&& int.TryParse(policyName[Prefix.Length..], out var age))
{
var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new MinimumAgeRequirement(age))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
}
builder.Services.AddSingleton<IAuthorizationPolicyProvider, MinimumAgePolicyProvider>();
Always delegate unknown policy names to DefaultAuthorizationPolicyProvider so named policies
registered in AddAuthorization still work.
Authorization in minimal APIs
// Per endpoint:
app.MapGet("/premium", () => "content").RequireAuthorization("PremiumUser");
// Group:
var api = app.MapGroup("/api").RequireAuthorization();
api.MapGet("/orders", (IOrderService svc) => svc.GetAll());
api.MapGet("/health", () => Results.Ok()).AllowAnonymous();
// Inject ClaimsPrincipal directly:
app.MapDelete("/orders/{id}", async (int id, ClaimsPrincipal user, IOrderService svc) =>
{
var authResult = await authzService.AuthorizeAsync(user, await svc.GetAsync(id), DocumentOperations.Delete);
if (!authResult.Succeeded) return Results.Forbid();
await svc.DeleteAsync(id);
return Results.NoContent();
}).RequireAuthorization();
Recap
ASP.NET Core authorization separates what's required (requirements) from how to check it
(handlers). Named policies are the composable unit — define once, apply anywhere. Role-based checks
are a convenient shortcut for simple cases. Resource-based authorization uses IAuthorizationService
for decisions that depend on the data being accessed. The fallback policy pattern makes the entire
app secure by default. Dynamic policies from IAuthorizationPolicyProvider eliminate boilerplate
when the policy space is parameterized.