Why Policy-Based Authorization Wins for External-Facing APIs
When you open an API to the outside world, the single most consequential design
decision is how you express "who is allowed to do what." It's tempting to
reach for the model you already use internally — bearer tokens with role checks
like [Authorize(Roles = "Admin,Publisher")]. That model serves internal systems
well, but the moment an API crosses your trust boundary, policy-based
(scope/claims) authorization becomes the better choice. This post explains why.
The problem with roles at the edge
Role-based authorization answers a question about who someone is: "Is this caller an Admin?" That works beautifully inside a system you control, where the set of roles is small, stable, and meaningful to your own team.
But an external API is consumed by people you've never met, integrating against your data for reasons you can't fully predict. The question you actually need to answer at the edge is not "who is this partner?" but "what has this partner been granted permission to do?" Those are different questions, and roles answer the wrong one.
Concretely, roles fall down at the boundary because:
- Roles are coarse. A partner who only needs to read schedules should not be
carrying a role that also implies they can read financial results or customer
lists. With roles you end up either over-granting or inventing a combinatorial
explosion of micro-roles (
ScheduleReader,ScheduleAndResultsReader, …). - Roles leak intent.
Admin/Publisher/Operatorare internal concepts. Exposing them to partners couples your external contract to your internal org model. Rename a role internally and you've broken a partner. - Roles don't compose with the rest of the edge. Rate limiting, tenant isolation, usage metering — these all want to partition on the partner and what they can touch, which is naturally claims-shaped, not role-shaped.
The model: scopes as policies
Give each granular capability a scope, and each scope its own named authorization policy. Keep the scopes in one canonical place:
public static class ApiScopes
{
public const string OrdersRead = "Orders.Read";
public const string OrdersWrite = "Orders.Write";
public const string InvoicesRead = "Invoices.Read";
public const string ShipmentsRead = "Shipments.Read";
public static readonly string[] All =
[OrdersRead, OrdersWrite, InvoicesRead, ShipmentsRead];
}
A partner is granted a subset of these scopes. Every scope becomes a policy at startup — no per-scope boilerplate, just a loop over the canonical list:
var authorization = builder.Services.AddAuthorizationBuilder();
foreach (var scope in ApiScopes.All)
{
authorization.AddPolicy(scope, policy =>
policy.AddRequirements(new ScopeRequirement(scope)));
}
And every endpoint declares the capability it needs — not the identity it expects:
[HttpGet("orders")]
[Authorize(Policy = ApiScopes.OrdersRead)]
public async Task<ActionResult<PagedResult<OrderDto>>> GetOrders(...) { ... }
[HttpGet("invoices/{id}")]
[Authorize(Policy = ApiScopes.InvoicesRead)]
public async Task<ActionResult<InvoiceDto>> GetInvoice(...) { ... }
Read the controller and you can see the contract: this route requires
Orders.Read; that one requires Invoices.Read. The authorization is part of
the endpoint's documented surface, not buried in a role table somewhere.
How the decision actually gets made
The enforcement is a tiny, single-responsibility handler. It does exactly one thing: check whether the authenticated principal carries the scope claim the policy demands.
public sealed class ScopeAuthorizationHandler
: AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
var hasScope = context.User
.FindAll("scope")
.Any(c => string.Equals(c.Value, requirement.Scope, StringComparison.Ordinal));
if (hasScope)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Where do those scope claims come from? Not from the request. Your authentication handler resolves the partner's credential (an opaque API key, a token, whatever you use), then mints the claims server-side from what the partner has actually been granted:
// After validating the credential, build the principal from stored grants.
var claims = new List<Claim> { new("partner_id", partner.Id.ToString()) };
claims.AddRange(partner.Scopes.Select(s => new Claim("scope", s)));
claims.AddRange(partner.AllowedTenantIds.Select(t => new Claim("tenant", t.ToString())));
var identity = new ClaimsIdentity(claims, "ApiKey");
return AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), "ApiKey"));
This is the crucial property: the caller never asserts their own permissions. They present an opaque credential; the server decides what it can do. There is no token the partner can tamper with to widen their access.
Why this is better for an outward-facing API
1. Least privilege becomes the default, not an afterthought
Because capability is granted scope-by-scope, the natural starting point for a new
partner is nothing, and you add exactly the scopes their integration needs. A
read-only reporting partner gets Orders.Read and stops there. They literally
cannot reach a write endpoint — the Orders.Write policy fails with a clean 403 —
even though the code is deployed and live. Least privilege is enforced by the
authorization layer, not by hoping nobody calls the wrong URL.
2. The authorization model and the public contract are the same thing
Scopes are partner-facing vocabulary. They appear in your API docs, in the admin
screen where someone grants them, and in the [Authorize(Policy = ...)] attribute
on each route. There is one shared language for "what this credential can do,"
which means the docs can't drift from the enforcement. Contrast that with roles,
where the internal role names mean nothing to a partner and have to be translated.
3. It composes with the rest of the edge concerns
Once identity and capability are expressed as claims, every other boundary concern plugs into the same principal:
Tenant isolation reads the granted tenant claims — and only those claims, never request input — so a partner physically cannot widen their own scope:
// All values come from the credential's claims, never from request input. public IReadOnlyCollection<long> AllowedTenantIds => User.FindAll("tenant").Select(c => long.Parse(c.Value)).ToArray();Rate limiting partitions on the partner-id claim and reads the partner's per-window limits straight from the principal — no extra credential lookup.
Usage metering records the partner-id claim for every request.
All of these are downstream of the same authenticated principal. Roles would give you one of these pieces (a yes/no on identity) and leave the rest to bespoke code.
4. Fine-grained changes don't require code changes
Granting or revoking a capability for a partner is data — flip which scope rows
are linked to the partner and invalidate the cached credential. No redeploy, no
new role, no attribute edits. The policy set is derived from the canonical scope
list, so adding a brand-new capability is: add one constant, add one
[Authorize(Policy = ...)]. The policy registration loop picks it up
automatically.
5. Failure modes are clean and non-leaky
Policy failures map to a single, predictable shape. A missing scope is a 403
with a partner-safe body; a bad/expired/revoked credential is a 401; an attempt
to reach a tenant you weren't granted returns 404 (so you can't even probe for
the existence of resources outside your tenant). Each concern produces its own
standardized response without entangling identity, capability, and tenancy into
one tangled check.
When roles are still fine
None of this means roles are wrong — they're a great fit for an internal API,
where the consumers are your own front-end and a known, small set of human roles
(Admin, Operator, Viewer, …). Inside the trust boundary, "who you are" is a
reasonable proxy for "what you can do." The distinction is the boundary itself:
| Concern | Internal API (roles) | External API (scopes/policies) |
|---|---|---|
| Question answered | Who is the user? | What is this credential permitted to do? |
| Granularity | Coarse, per-role | Fine, per-capability |
| Vocabulary | Internal org concepts | Public, documented contract |
| Source of truth | Token the client holds | Server-side grant, minted into claims |
| Changing access | Often a code/role change | Data change + cache bust |
| Composes with tenancy/rate-limit/metering | Awkward | Naturally (shared claims) |
Takeaways
- At a trust boundary, authorize on capability, not identity. Scopes answer the question that actually matters for an external caller.
- Make the server the sole source of permissions. The partner presents an opaque credential; the API mints claims from server-side grants. There's nothing for the caller to forge.
- Derive policies from one canonical scope list. Adding a capability stays a two-line change, and your docs, admin UI, and enforcement share one vocabulary.
- Let everything at the edge ride on the same principal. Tenant isolation, rate limiting, and usage metering all read the same claims, so the boundary is enforced consistently in one place.
Policy-based authorization costs a little more wiring up front — a requirement, a handler, a registration loop. For an API exposed to the outside world, that small investment is what turns "we hope nobody calls the wrong endpoint" into "they provably can't."