← Files Duende SkillsARCHIVED FILE

skills/aspnetcore-authorization/SKILL.md

15.3 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

---
name: aspnetcore-authorization
description: ASP.NET Core authorization patterns including policy-based authorization, IAuthorizationHandler implementations, scope-based authorization for APIs, authorization middleware configuration, and minimal API authorization.
invocable: false
---

# ASP.NET Core Authorization

## When to Use This Skill

Use this skill when:
- Implementing policy-based authorization in ASP.NET Core
- Protecting API endpoints with scope-based or claim-based checks
- Writing custom `IAuthorizationHandler` implementations
- Configuring authorization for Minimal APIs, controllers, or Razor Pages
- Enforcing role-based access control using OIDC claims
- Combining multiple authorization requirements into composite policies

## Core Principles

1. **Policy-Based Over Role-Based** — Use authorization policies instead of `[Authorize(Roles = "...")]`. Policies are composable, testable, and decoupled from claim types.
2. **Scope ≠ Permission** — OAuth scopes represent what the *client* is allowed to do. User claims represent what the *user* is allowed to do. Combine both for proper API authorization.
3. **Authorization is Separate from Authentication** — Authentication (see `aspnetcore-authentication`) establishes identity. Authorization decides access based on that identity.
4. **Fail Closed** — Default to denying access. Require explicit authorization on all endpoints.
5. **Resource-Based When Needed** — For decisions that depend on the resource being accessed (e.g., "can this user edit this document?"), use `IAuthorizationService` with resource-based authorization.

## Related Skills

- `aspnetcore-authentication` — Authentication middleware that provides the identity
- `claims-authorization` — Advanced claims transformation and authorization patterns
- `identityserver-configuration` — Server-side scope and resource configuration
- `oauth-oidc-protocols` — Understanding scopes, claims, and token contents

Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/

---

## Pattern 1: Basic Policy-Based Authorization

Define policies at startup and reference them on endpoints:

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization(options =>
{
    // Policy that requires the user to be authenticated
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();

    // Policy requiring a specific scope in the access token
    options.AddPolicy("read:catalog", policy =>
        policy.RequireClaim("scope", "catalog.read"));

    // Policy requiring a specific role
    options.AddPolicy("admin", policy =>
        policy.RequireRole("admin"));

    // Policy combining multiple requirements
    options.AddPolicy("catalog-editor", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("scope", "catalog.write");
        policy.RequireClaim("department", "merchandising");
    });
});
```

### Applying Policies

```csharp
// Minimal API
app.MapGet("/products", () => Results.Ok())
    .RequireAuthorization("read:catalog");

// Controller
[Authorize(Policy = "catalog-editor")]
public class CatalogController : ControllerBase { }

// Razor Page
[Authorize(Policy = "admin")]
public class AdminModel : PageModel { }
```

---

## Pattern 2: Scope-Based Authorization for APIs

APIs protected by IdentityServer need to validate scopes from the access token. Scopes represent what the *client application* is permitted to do.

### Simple Scope Check

```csharp
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("api.read", policy =>
        policy.RequireClaim("scope", "catalog.read"));

    options.AddPolicy("api.write", policy =>
        policy.RequireClaim("scope", "catalog.write"));
});

app.MapGet("/products", GetProducts).RequireAuthorization("api.read");
app.MapPost("/products", CreateProduct).RequireAuthorization("api.write");
```

### Scope as Space-Delimited String

When `EmitScopesAsSpaceDelimitedStringInJwt = true` on IdentityServer, scopes arrive as a single space-delimited string rather than an array. Use a custom handler:

```csharp
public class ScopeRequirement : IAuthorizationRequirement
{
    public string Scope { get; }
    public ScopeRequirement(string scope) => Scope = scope;
}

public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ScopeRequirement requirement)
    {
        var scopeClaim = context.User.FindFirst("scope");
        if (scopeClaim is null)
        {
            return Task.CompletedTask; // Not handled = denied
        }

        // Handle both array claims and space-delimited string
        var scopes = scopeClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (scopes.Contains(requirement.Scope))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

// Registration
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("catalog.read", policy =>
        policy.Requirements.Add(new ScopeRequirement("catalog.read")));
});
```

---

## Pattern 3: Custom Authorization Handlers

For complex authorization logic, implement `IAuthorizationHandler`:

```csharp
// Requirement — what needs to be satisfied
public class MinimumTenureRequirement : IAuthorizationRequirement
{
    public int MinimumYears { get; }
    public MinimumTenureRequirement(int years) => MinimumYears = years;
}

// Handler — how to evaluate the requirement
public class MinimumTenureHandler : AuthorizationHandler<MinimumTenureRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumTenureRequirement requirement)
    {
        var hireDateClaim = context.User.FindFirst("hire_date");
        if (hireDateClaim is null)
        {
            return Task.CompletedTask;
        }

        if (DateTimeOffset.TryParse(hireDateClaim.Value, out var hireDate))
        {
            var tenure = DateTimeOffset.UtcNow - hireDate;
            if (tenure.TotalDays >= requirement.MinimumYears * 365.25)
            {
                context.Succeed(requirement);
            }
        }

        return Task.CompletedTask;
    }
}

// Registration
builder.Services.AddSingleton<IAuthorizationHandler, MinimumTenureHandler>();
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("senior-staff", policy =>
        policy.Requirements.Add(new MinimumTenureRequirement(5)));
});
```

### Multiple Handlers for One Requirement

When any handler succeeding should grant access (OR logic):

```csharp
// Both handlers evaluate the same requirement
// If EITHER succeeds, the requirement is satisfied
public class AdminByRoleHandler : AuthorizationHandler<AdminRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, AdminRequirement requirement)
    {
        if (context.User.IsInRole("admin"))
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

public class AdminByDepartmentHandler : AuthorizationHandler<AdminRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, AdminRequirement requirement)
    {
        if (context.User.HasClaim("department", "it-operations"))
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}
```

> **Key concept:** Multiple requirements in a policy use AND logic (all must be satisfied). Multiple handlers for the same requirement use OR logic (any can satisfy it).

---

## Pattern 4: Resource-Based Authorization

When authorization depends on the resource being accessed, use `IAuthorizationService`:

```csharp
public class DocumentAuthorizationHandler
    : AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OperationAuthorizationRequirement requirement,
        Document resource)
    {
        var userId = context.User.FindFirst("sub")?.Value;

        if (requirement == Operations.Read)
        {
            // Anyone in the same department can read
            if (context.User.HasClaim("department", resource.Department))
                context.Succeed(requirement);
        }
        else if (requirement == Operations.Edit)
        {
            // Only the owner can edit
            if (resource.OwnerId == userId)
                context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

public static class Operations
{
    public static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) };
    public static readonly OperationAuthorizationRequirement Edit = new() { Name = nameof(Edit) };
}
```

### Using in a Controller

```csharp
public class DocumentsController : ControllerBase
{
    private readonly IAuthorizationService _authz;
    private readonly IDocumentRepository _docs;

    public DocumentsController(IAuthorizationService authz, IDocumentRepository docs)
    {
        _authz = authz;
        _docs = docs;
    }

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

        var result = await _authz.AuthorizeAsync(
            User,
            document,
            Operations.Read);

        if (!result.Succeeded) return Forbid();

        return Ok(document);
    }
}
```

---

## Pattern 5: Minimal API Authorization

Minimal APIs use the same authorization system with a fluent API:

```csharp
// Require authentication on all endpoints by default
app.MapGet("/public", () => "Anyone can see this")
    .AllowAnonymous();

app.MapGet("/products", GetProducts)
    .RequireAuthorization("read:catalog");

app.MapPost("/products", CreateProduct)
    .RequireAuthorization("catalog-editor");

// Inline policy
app.MapDelete("/products/{id}", DeleteProduct)
    .RequireAuthorization(policy =>
        policy.RequireClaim("scope", "catalog.write")
              .RequireRole("admin"));

// Group-level authorization
var adminGroup = app.MapGroup("/admin")
    .RequireAuthorization("admin");

adminGroup.MapGet("/users", GetUsers);
adminGroup.MapPost("/users", CreateUser);
```

---

## Pattern 6: Combining Client Scope + User Claims

In APIs protected by IdentityServer, proper authorization often requires checking *both* the client's scope and the user's claims:

```csharp
public class ApiWriteRequirement : IAuthorizationRequirement { }

public class ApiWriteHandler : AuthorizationHandler<ApiWriteRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ApiWriteRequirement requirement)
    {
        // Check 1: Client must have the write scope
        var hasScope = context.User.HasClaim(c =>
            c.Type == "scope" && c.Value.Split(' ').Contains("catalog.write"));

        // Check 2: User must be in the editor role
        var isEditor = context.User.IsInRole("editor");

        if (hasScope && isEditor)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}
```

> **Why both?** A malicious client could request broad scopes, but the user may not have permission. A privileged user operating through a restricted client should be limited by that client's scopes.

---

## Pattern 7: Fallback and Default Policies

```csharp
builder.Services.AddAuthorization(options =>
{
    // DefaultPolicy: applied when [Authorize] has no policy name
    options.DefaultPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();

    // FallbackPolicy: applied to endpoints with NO [Authorize] attribute
    // Setting this makes all endpoints require authentication by default
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});
```

| Policy | Applied When | Use Case |
|--------|-------------|----------|
| `DefaultPolicy` | `[Authorize]` with no policy name | Basic "must be logged in" check |
| `FallbackPolicy` | Endpoints with no `[Authorize]` attribute | Secure-by-default for APIs |

> **Tip:** Set `FallbackPolicy` to require authentication, then use `[AllowAnonymous]` only on endpoints that genuinely need it (health checks, public assets).

---

## Common Pitfalls

### 1. Using Role Strings Instead of Policies

```csharp
// ❌ WRONG — Hardcoded role strings scattered across controllers
[Authorize(Roles = "admin,superadmin,it-ops")]
public IActionResult Dashboard() { }

// ✅ CORRECT — Centralized policy
options.AddPolicy("dashboard-access", policy =>
    policy.RequireRole("admin", "superadmin", "it-ops"));

[Authorize(Policy = "dashboard-access")]
public IActionResult Dashboard() { }
```

### 2. Not Registering Authorization Handlers

```csharp
// ❌ WRONG — Handler exists but never registered
// Policy silently denies because no handler evaluates the requirement

// ✅ CORRECT — Register the handler in DI
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
```

### 3. Calling context.Fail() in Handlers

`context.Fail()` **actively denies** authorization regardless of what other handlers say — it's a hard veto. Not calling `context.Succeed()` simply means "I have no opinion"; other handlers can still satisfy the requirement.

```csharp
// ❌ WRONG — Fail() is a hard veto: it denies even if another handler would succeed
protected override Task HandleRequirementAsync(...)
{
    if (!context.User.HasClaim("scope", "api.read"))
        context.Fail(); // Forces denial — blocks all other handlers permanently!
    return Task.CompletedTask;
}

// ✅ CORRECT — Simply don't call Succeed(); let other handlers try
protected override Task HandleRequirementAsync(...)
{
    if (context.User.HasClaim("scope", "api.read"))
        context.Succeed(requirement);
    // Not calling Succeed() means "I don't know" — other handlers may still succeed
    return Task.CompletedTask;
}
```

> Only call `context.Fail()` when you need to **guarantee** denial even if other handlers would approve (e.g., a security blocklist check). In most cases, simply omit the `Succeed()` call.

### 4. Ignoring Client vs User Authorization

```csharp
// ❌ WRONG — Only checking user role, ignoring client scope
options.AddPolicy("write", p => p.RequireRole("editor"));
// A client without the write scope could still pass this check

// ✅ CORRECT — Check both scope and user claims
options.AddPolicy("write", p =>
{
    p.RequireClaim("scope", "catalog.write"); // Client permission
    p.RequireRole("editor");                   // User permission
});
```

---

## Resources

- [Authorization in ASP.NET Core — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/introduction)
- [Policy-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies)
- [Resource-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/resourcebased)
- [Protecting APIs — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/)
- [API Authorization — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/)

SHA-256: 11d0529bdc4215c935a39872c518335c7d6f8ff318c84ce6ee8fb7694cb006e0