Skip to content

Authorization Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

ASP.NET Core authorization interview questions — policy-based authorization, custom requirements and handlers, resource-based auth, and dynamic policies.

Read the in-depth guidePolicy-Based Authorization in ASP.NET Core(opens in new tab)
15 of 15

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.

Policies are registered in AddAuthorization (or AddAuthorizationBuilder .NET 8+) and applied with [Authorize(Policy = "...")] or RequireAuthorization("...").

// .NET 8+ fluent builder — cleaner API:
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"))

    .SetDefaultPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build())

    .SetFallbackPolicy(null); // null = allow anonymous by default (change if needed)

// .NET 6/7 style:
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AtLeast18", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Apply in controllers:
[Authorize(Policy = "PremiumUser")]
public class PremiumController : ControllerBase { }

// Apply in minimal APIs:
app.MapGet("/premium", () => "premium content")
   .RequireAuthorization("PremiumUser");

// Combine policies — both must pass (AND logic):
app.MapPost("/admin/premium", () => "admin + premium")
   .RequireAuthorization("AdminOnly", "PremiumUser");

Rule of thumb: Define policies centrally during startup — avoid scattering role strings across controllers. Centralizing rules means a single change updates every endpoint that uses that policy.

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.

[Authorize] is an action filter that runs after authentication and before the action method. It evaluates the user's ClaimsPrincipal against the specified roles, policy, or authentication scheme.

// Bare [Authorize] — requires IsAuthenticated = true (default policy):
[Authorize]
public IActionResult Profile() => Ok(User.Identity!.Name);

// Role — checks ClaimTypes.Role claim (comma = OR, stacked = AND):
[Authorize(Roles = "Admin,SuperAdmin")]       // Admin OR SuperAdmin
public IActionResult AdminArea() => Ok();

[Authorize(Roles = "Admin")]
[Authorize(Roles = "Manager")]               // Admin AND Manager
public IActionResult AdminManager() => Ok();

// Named policy:
[Authorize(Policy = "PremiumUser")]
public IActionResult PremiumContent() => Ok();

// Specific authentication scheme (evaluate with this scheme only):
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public IActionResult ApiEndpoint() => Ok();

// Controller-level + action-level (both must pass):
[Authorize]                       // all actions require auth
public class AccountController : ControllerBase
{
    [AllowAnonymous]              // this action overrides — allows anonymous
    public IActionResult Login() => Ok();

    [Authorize(Roles = "Admin")] // this action requires auth + Admin role
    public IActionResult AdminAction() => Ok();

    public IActionResult Profile() => Ok(); // uses controller-level [Authorize]
}

Rule of thumb: Prefer [Authorize] at the controller level with [AllowAnonymous] on exceptions — it's harder to accidentally expose an endpoint. Avoid sprinkling [AllowAnonymous] at the controller level with [Authorize] on individual actions.

IAuthorizationService allows runtime authorization checks in service/controller code rather than declarative attribute-only checks. Inject it and call AuthorizeAsync.

// Controller — inject IAuthorizationService:
[ApiController]
[Route("api/documents")]
public class DocumentsController : ControllerBase
{
    private readonly IAuthorizationService _authz;
    private readonly IDocumentRepository _repo;

    public DocumentsController(IAuthorizationService authz, IDocumentRepository repo)
    {
        _authz = authz;
        _repo  = repo;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var doc = await _repo.GetAsync(id);
        if (doc is null) return NotFound();

        // Imperative check — policy name:
        var result = await _authz.AuthorizeAsync(User, "CanReadDocuments");
        if (!result.Succeeded) return Forbid();

        return Ok(doc);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var doc = await _repo.GetAsync(id);
        if (doc is null) return NotFound();

        // Imperative check — resource-based with specific requirement:
        var result = await _authz.AuthorizeAsync(User, doc, DocumentOperations.Delete);
        if (!result.Succeeded) return Forbid();

        await _repo.DeleteAsync(id);
        return NoContent();
    }
}

// In a service (not a controller) — the user must be passed in:
public class DocumentService
{
    private readonly IAuthorizationService _authz;

    public async Task<bool> CanDeleteAsync(ClaimsPrincipal user, Document doc)
    {
        var result = await _authz.AuthorizeAsync(user, doc, DocumentOperations.Delete);
        return result.Succeeded;
    }
}

Rule of thumb: Use IAuthorizationService when the authorization decision depends on runtime data (the resource itself). Use [Authorize(Policy = "...")] for static checks that don't need a resource instance.

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.

Add AuthorizeFilter globally in MVC options, or set a fallback policy. Both approaches require authentication on every route unless [AllowAnonymous] is present.

// Option 1 — global MVC filter (controllers only, not minimal APIs):
builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

// Option 2 — fallback policy (works for controllers + minimal APIs):
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build());

// Option 3 — MapGroup with RequireAuthorization (minimal API scoping):
var api = app.MapGroup("/api").RequireAuthorization();

// In all three cases, open specific endpoints with [AllowAnonymous]:
[AllowAnonymous]
[HttpGet("public-status")]
public IActionResult PublicStatus() => Ok("online");

// Recommendation: prefer the fallback policy over the MVC filter
// because it covers both controllers and minimal API endpoints.

Rule of thumb: For API-only applications, set the fallback policy to RequireAuthenticatedUser — it's the single secure-by-default knob that covers every routing mechanism.

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.

Authorization middleware relies on HttpContext.User being populated before it runs. If UseAuthorization is placed before UseAuthentication, every request arrives with an anonymous principal and all [Authorize] checks fail or pass incorrectly.

// Correct order — build, authenticate, then authorize:
var app = builder.Build();

app.UseRouting();          // 1. resolve the endpoint (needed by auth middleware)
app.UseAuthentication();   // 2. populate HttpContext.User from cookie or token
app.UseAuthorization();    // 3. evaluate [Authorize] against the populated User
app.MapControllers();      // 4. dispatch to the controller action

// Wrong — authorization runs before the user identity is established:
// app.UseAuthorization();  // HttpContext.User = anonymous ClaimsPrincipal
// app.UseAuthentication(); // too late — authorization already ran

// Wrong — routing not called before auth in older templates:
// app.UseAuthentication();
// app.UseAuthorization();
// app.UseRouting(); // endpoint metadata (like [Authorize]) not yet resolved

// Effect of wrong order:
// [Authorize] endpoints return 401 for every request, including authenticated users.
// Resource-based checks via IAuthorizationService still work (called in action bodies)
// but attribute-based checks at the middleware level silently fail.

// Note: UseEndpoints (pre-.NET 6) must also come after UseRouting:
// app.UseRouting();
// app.UseAuthentication();
// app.UseAuthorization();
// app.UseEndpoints(e => e.MapControllers());

Rule of thumb: The canonical order is UseRouting → UseAuthentication → UseAuthorization → MapControllers/MapGet. In .NET 6+ minimal hosting this is the default, but always verify when customizing the pipeline.

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.

Override IAuthorizationMiddlewareResultHandler (ASP.NET Core 5+) or handle failures in ProblemDetails middleware. For specific schemes, use the scheme's event callbacks.

// Option 1 — custom IAuthorizationMiddlewareResultHandler:
public class CustomAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
    private readonly AuthorizationMiddlewareResultHandler _default = new();

    public async Task HandleAsync(
        RequestDelegate next,
        HttpContext context,
        AuthorizationPolicy policy,
        PolicyAuthorizationResult authorizeResult)
    {
        if (authorizeResult.Forbidden && authorizeResult.AuthorizationFailure is not null)
        {
            // Return structured JSON instead of plain 403:
            context.Response.StatusCode  = StatusCodes.Status403Forbidden;
            context.Response.ContentType = "application/problem+json";
            await context.Response.WriteAsJsonAsync(new
            {
                type   = "https://tools.ietf.org/html/rfc7231#section-6.5.3",
                title  = "Forbidden",
                status = 403,
                detail = "You do not have permission to perform this action.",
                failedRequirements = authorizeResult.AuthorizationFailure
                    .FailedRequirements
                    .Select(r => r.GetType().Name),
            });
            return;
        }

        await _default.HandleAsync(next, context, policy, authorizeResult);
    }
}

// Register (replaces the default handler):
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler,
    CustomAuthorizationResultHandler>();

// Option 2 — UseStatusCodePages for simple HTML/text responses:
app.UseStatusCodePages(async ctx =>
{
    if (ctx.HttpContext.Response.StatusCode == 403)
    {
        ctx.HttpContext.Response.ContentType = "application/json";
        await ctx.HttpContext.Response.WriteAsJsonAsync(new { error = "Access denied" });
    }
});

// Option 3 — for cookie auth: customize the redirect rather than status code:
.AddCookie(options =>
{
    options.Events.OnRedirectToAccessDenied = ctx =>
    {
        ctx.Response.StatusCode  = 403;
        ctx.Response.ContentType = "application/json";
        return ctx.Response.WriteAsJsonAsync(new { error = "Forbidden" });
    };
});

Rule of thumb: Implement IAuthorizationMiddlewareResultHandler for API projects that need RFC 7807 Problem Details on every auth failure. For cookie-based web apps, override the OnRedirectToAccessDenied event to avoid a redirect loop on API calls.

More ways to practice

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

or
Join our WhatsApp Channel