Authorization

Authorization in ASP.NET Core

Authorization determines what an authenticated user is allowed to do. It runs after authentication (which establishes who the user is) and evaluates whether the current ClaimsPrincipal has permission to access a resource or perform an action. ASP.NET Core supports three authorization models: role-based, claims-based, and policy-based (the most flexible). Resource-based authorization handles cases where the decision depends on the specific resource being accessed.

Role-Based Authorization

The simplest model: restrict access to users with a specific role claim.

// Restrict to users with the "Admin" role
[Authorize(Roles = "Admin")]
public IActionResult AdminDashboard() => Ok();

// Multiple roles (OR logic — any of these roles grants access)
[Authorize(Roles = "Admin,Manager")]
public IActionResult Reports() => Ok();

Roles are stored as claims (ClaimTypes.Role) in the JWT or cookie. Role-based authorization is simple but inflexible — adding a new permission requires adding a new role or changing role assignments.

Policy-Based Authorization

Policies are named requirements evaluated against the ClaimsPrincipal. They decouple the authorization logic from the controller.

// Register policies in Program.cs
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanApproveOrders", policy =>
        policy.RequireClaim("department", "Finance")
              .RequireRole("Manager"));

    options.AddPolicy("MinimumAge18", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Apply a policy to an endpoint
[Authorize(Policy = "CanApproveOrders")]
public IActionResult ApproveOrder(string orderId) => Ok();

Custom requirements implement IAuthorizationRequirement and are evaluated by a handler:

public sealed class MinimumAgeRequirement(int minimumAge) : IAuthorizationRequirement
{
    public int MinimumAge { get; } = minimumAge;
}

public sealed class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        var birthDateClaim = context.User.FindFirst("birthdate");
        if (birthDateClaim is null) return Task.CompletedTask;

        var birthDate = DateOnly.Parse(birthDateClaim.Value);
        var age = DateOnly.FromDateTime(DateTime.Today).Year - birthDate.Year;

        if (age >= requirement.MinimumAge)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

// Register the handler
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();

Resource-Based Authorization

When the authorization decision depends on the specific resource (e.g., "can this user edit this document?"), inject IAuthorizationService and evaluate imperatively:

public sealed class DocumentsController(IAuthorizationService authz, IDocumentRepository docs)
    : ControllerBase
{
    [HttpPut("{id}")]
    public async Task<IActionResult> Update(string id, DocumentDto dto)
    {
        var document = await docs.FindAsync(id);
        if (document is null) return NotFound();

        // Check if the current user can edit this specific document
        var result = await authz.AuthorizeAsync(User, document, "CanEditDocument");
        if (!result.Succeeded) return Forbid();

        // Proceed with update...
        return Ok();
    }
}

The "CanEditDocument" policy handler receives the document as the resource and can check ownership, team membership, or any other resource-specific condition.

Pitfalls

Returning 404 Instead of 403 for Unauthorized Resources

What goes wrong: returning NotFound() when a user tries to access a resource they don't own leaks information about the resource's existence.

Why it happens: developers return 404 to hide that the resource exists, but this is inconsistent — authenticated users who own the resource get 200, others get 404.

Mitigation: for sensitive resources, return 404 consistently (don't reveal existence). For non-sensitive resources, return 403 Forbidden so the client knows the resource exists but access is denied. Be consistent within an API.

Authorization Logic in Controllers

What goes wrong: if (user.Role == "Admin" || user.Id == resource.OwnerId) scattered across controller actions. Logic is duplicated and hard to audit.

Why it happens: it's the path of least resistance when adding a quick permission check.

Mitigation: move all authorization logic into policies and handlers. Controllers should only call authz.AuthorizeAsync() or use [Authorize(Policy = "...")] — never contain authorization logic directly.

Tradeoffs

Questions

References


Whats next