From duende-skills
Implements ASP.NET Core authorization patterns including policy-based, scope-based, custom IAuthorizationHandler, and minimal API authorization.
How this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:aspnetcore-authorizationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when:
Use this skill when:
IAuthorizationHandler implementations[Authorize(Roles = "...")]. Policies are composable, testable, and decoupled from claim types.aspnetcore-authentication) establishes identity. Authorization decides access based on that identity.IAuthorizationService with resource-based authorization.aspnetcore-authentication — Authentication middleware that provides the identityclaims-authorization — Advanced claims transformation and authorization patternsidentityserver-configuration — Server-side scope and resource configurationoauth-oidc-protocols — Understanding scopes, claims, and token contentsDocs: https://docs.duendesoftware.com/identityserver/tokens/authorization
Define policies at startup and reference them on endpoints:
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");
});
});
// 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 { }
APIs protected by IdentityServer need to validate scopes from the access token. Scopes represent what the client application is permitted to do.
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");
When EmitScopesAsSpaceDelimitedStringInJwt = true on IdentityServer, scopes arrive as a single space-delimited string rather than an array. Use a custom handler:
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")));
});
For complex authorization logic, implement IAuthorizationHandler:
// 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)));
});
When any handler succeeding should grant access (OR logic):
// 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).
When authorization depends on the resource being accessed, use IAuthorizationService:
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) };
}
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);
}
}
Minimal APIs use the same authorization system with a fluent API:
// 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);
In APIs protected by IdentityServer, proper authorization often requires checking both the client's scope and the user's claims:
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.
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
FallbackPolicyto require authentication, then use[AllowAnonymous]only on endpoints that genuinely need it (health checks, public assets).
// ❌ 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() { }
// ❌ 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>();
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.
// ❌ 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 theSucceed()call.
// ❌ 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
});
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skillsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.