A Policy Per Endpoint: How Declarative Security Compiles
Permissions(), Claims() and Scopes() in Configure() feel like magic until you read the mapping code - every endpoint gets its own named authorization policy built from closures at startup, and roles ride a plain AuthorizeAttribute. Part 7 of FastEndpoints in Depth.
Authorization in ASP.NET Core is genuinely good and genuinely verbose. Policies are named strings registered far from the endpoints that use them; requirements are classes; handlers are more classes; and the distance between “this endpoint needs the order:refund permission” and the code that enforces it can be four files. FastEndpoints collapses that distance to one line in Configure():
public override void Configure()
{
Post("/orders/{id}/refund");
Permissions("order:refund");
Roles("supervisor", "admin");
AuthSchemes("Bearer");
}
The interesting question — the one this post answers from the source — is what those calls become. Because they don't bypass ASP.NET Core authorization; they compile into it.
One named policy per endpoint
During route mapping (the MapFastEndpoints walk from Part 2), every endpoint that requires authorization gets a dedicated policy registered into the standard AuthorizationOptions, under a generated name (epPolicy:... derived from the endpoint type). The policy body is built from closures over the endpoint definition:
// Src/Library/Main/MainExtensions.cs (FastEndpoints, MIT) - permissions, Any mode
b.RequireAssertion(
x => x.User.Claims.Any(
c => string.Equals(c.Type, Cfg.SecOpts.PermissionsClaimType, StringComparison.OrdinalIgnoreCase) &&
ep.AllowedPermissions.Contains(c.Value, StringComparer.Ordinal)));
Permissions, scopes, and claim-type requirements each get an assertion like this, in Any-or-All flavours (Permissions(...) means any of these; PermissionsAll(...) means all of them — same pairing for scopes and claims). A custom Policy(b => ...) builder callback is appended last, so you can drop down to raw RequireAssertion/AddRequirements for anything the shorthand doesn't cover.
Roles and schemes deliberately do not live in the policy. The source comment spells out the split: claim/permission/scope requirements go in the per-endpoint policy; roles and authentication schemes are set on the AuthorizeAttribute that accompanies it:
// Src/Library/Main/MainExtensions.cs (FastEndpoints, MIT)
var attr = new AuthorizeAttribute { Policy = p };
if (ep.AuthSchemeNames is not null)
attr.AuthenticationSchemes = string.Join(',', ep.AuthSchemeNames);
if (ep.AllowedRoles is not null)
attr.Roles = string.Join(',', ep.AllowedRoles);
That attribute (plus any pre-built policy names you added with Policies("ExistingPolicy")) is attached to the mapped route via RequireAuthorization(...) — which means enforcement is done by the standard authorization middleware, before FastEndpoints ever sees the request. Not custom auth code in the endpoint pipeline: metadata on a route, evaluated by the framework, visible to any tooling that understands endpoint metadata. If you dump app.Services.GetRequiredService<IAuthorizationPolicyProvider>() in a debugger, your endpoints' policies are right there alongside your hand-written ones.
Two implications worth internalizing:
- Authorization failures never enter the endpoint. A 401/403 happens in middleware; your handler, binder, validators and processors don't run. If you want to observe denied requests, that observation belongs in middleware too.
AllowAnonymous()is per-verb capable. The mapping code checks anonymous verbs individually, soAllowAnonymous(Http.GET)on a multi-verb endpoint does what you'd hope.
Permissions are just claims — with a chosen name
The permission model that makes Permissions("order:refund") work is disarmingly simple: a permission is a claim of type "permissions" (configurable via Config.Security.PermissionsClaimType), and the check is claim-set membership. No database, no policy server — whatever you put in the token is what you have. The same applies to Scopes(), which parses the standard scope claim (space-delimited by default, parser swappable for the JSON-array variants some identity providers emit).
This is a deliberately thin model and I want to be honest about where it lands. It is token-bound authorization: revoking a permission means reissuing tokens (or adding revocation — Part 8). It has no notion of resource-based authorization — “can this user edit this order” still belongs in your handler or a resource authorization service. What the model buys you is the 80% case with zero infrastructure, and a vocabulary (Permissions, not Claims("permissions", ...)) that makes endpoint declarations read like an access-control matrix.
Where do permission strings come from? You can hand-manage constants, or use the AccessControl attribute + source generator combo: declaring AccessControl("Order_Refund") on endpoints generates a class of permission constants (with stable three-character hash codes — the GetAclHash SHA-256 truncation visible in Endpoint.Base.cs) so the client team and the token issuer share one generated vocabulary. That generator is covered with its siblings in Part 15.
The claims side of binding
Security and binding meet in two features from Part 4 worth re-mentioning now that the policy context is clear:
public class RefundRequest
{
public Guid OrderId { get; set; }
[FromClaim("sub")]
public string RequestedBy { get; set; } = "";
[HasPermission("order:refund:unlimited")]
public bool UnlimitedRefund { get; set; }
}
[FromClaim] makes identity data part of the DTO (missing claim = validation failure unless marked optional); [HasPermission] binds the result of a permission check as a boolean rather than gatekeeping. The distinction from Permissions() in Configure() matters: the endpoint-level call denies entry; the bound boolean lets one endpoint serve both privilege levels with branch logic. I use the former for “may you be here at all” and the latter for “how much may you do” — refund caps, field-level redaction, that sort of thing.
Default-secure, explicitly open
Endpoints require authentication by default — an endpoint with no AllowAnonymous() and no requirements still demands an authenticated user. The mapping code path makes this visible: anonymous verbs get AllowAnonymous(), everything else gets RequireAuthorization(...), no third option. For a public API surface you flip the default per endpoint, or via the global Configurator for a subtree — but the fact that forgetting produces a 401 rather than an open endpoint is the right failure direction, and not something Minimal APIs give you out of the box.
If you have ever audited a large MVC codebase for a missing [Authorize], you know exactly what that default is worth.
What the design teaches
The part I keep coming back to: FastEndpoints could have implemented authorization inside its pipeline — it already owns the request flow, and checking claims in ExecAsync would have been easier to write. Instead it spends its effort translating declarations into the framework's own policy system, gaining middleware-time enforcement, tooling compatibility, and composability with hand-written policies. It is the same philosophy as the routing trick in Part 3: don't replace the platform, drive it.
Declarative authorization is only half the security story — someone has to issue the tokens those policies consume. The FastEndpoints.Security package covers creation, refresh and revocation, including an abstract refresh-token service that is itself implemented as an endpoint: Part 8, JWTs, Refresh Tokens, and Revocation Without the Ceremony.