Skip to content

.NET Core · Security

Policy-Based Authorization in ASP.NET Core

7 min read Updated 2026-06-23 Share:

Practice Authorization interview questions

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 other Succeed call.
  • Return Task.CompletedTask without calling either when the handler doesn't apply (gives other handlers a chance).
  • One requirement can have multiple handlers — any Succeed passes 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

PolicyWhen applied
Default policyWhen [Authorize] is used with no parameters
Fallback policyFor 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel