Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

codewithmukesh
Back to blog
dotnet webapi-course 15 min read Lesson 98/151 Updated

Permission-Based Authorization in ASP.NET Core - A .NET 10 Guide

Build permission-based authorization in ASP.NET Core .NET 10 - a dynamic policy provider, runtime-managed role permissions, and what token-bound permissions really cost.

Build permission-based authorization in ASP.NET Core .NET 10 - a dynamic policy provider, runtime-managed role permissions, and what token-bound permissions really cost.

dotnet webapi-course

permission-based-authorization permissions authorization aspnet-core dotnet-10 iauthorizationpolicyprovider authorization-handler iauthorizationrequirement role-claims rolemanager dynamic-policies jwt claims aspnet-core-identity minimal-api api-security access-control rbac web-api

Mukesh Murugan
Mukesh Murugan
Solutions Architect · Microsoft MVP
Chapter 98 of 151
View course

.NET Web API Zero to Hero Course

From dotnet new to docker push - REST, EF Core 10, auth, caching, Clean Architecture, observability. 151 hands-on lessons, source on GitHub.

Permission-based authorization replaces hard-coded role checks with fine-grained permission strings that an administrator can grant and revoke while the application is running. Instead of scattering Roles = "Admin" across your endpoints, each endpoint demands one permission like Permissions.Products.Delete. Which roles hold that permission becomes data you change through an API call, not code you redeploy.

I first published this in January 2021, on .NET 5, with MVC controllers and Razor views. This is the full .NET 10 rebuild: Minimal APIs, no views, and two bugs from the original that I want to walk through honestly, because both fail silently.

It is also the capstone of my authorization series. Roles came first, then claims, then policies, and permissions are where all three pay off. Everything runs on a working .NET 10 repo with three seeded users. Let’s get into it.

What Is Permission-Based Authorization?

Permission-based authorization is a model where endpoints demand a named capability rather than a group membership. The endpoint says “the caller must hold Permissions.Products.Delete” and says nothing about who holds it.

That indirection is the entire value. A role is a label on a person; a permission is a statement about an action. When you check the action, the mapping from people to actions moves out of your source code and into your database, where an admin can edit it at two in the morning without waiting for a deployment slot.

In ASP.NET Core this is not a separate feature. It is policy-based authorization with two additions: permissions stored as claims on the role, and a custom policy provider that manufactures a policy for any permission name it sees.

Why Roles Stop Working

Roles are fine until they are not, and the failure is always the same shape.

You start with Admin, Manager, and User. Then finance needs to approve refunds but not issue them, so you add RefundApprover. Then support needs read access to orders but not customers, so you add OrderViewer. Two years later you have forty roles, half created to solve exactly one ticket, and nobody can tell you what Manager2 does.

The deeper problem is that role names are compiled in. RequireAuthorization("Admin") is a string baked into your assembly, so changing what an admin can do means editing code, reviewing it, and shipping it. A permission model inverts that: the endpoint’s demand is fixed, and the answer to “who satisfies it” is a row in a table.

Two questions tell you it is time to switch. Can an administrator change access levels without you deploying? Can you answer “what exactly can this person do” from one query? With roles scattered across endpoint attributes, both answers are no.

If you are still under five stable groups, stay on roles. The machinery below is real complexity, and you should only pay for it once those questions start costing you.

Setting Up the Demo API

The sample continues where the policy article’s project ended, so the JWT setup and Identity wiring are carried over rather than re-explained.

The complete source is on GitHub, tested on .NET 10 with Microsoft.AspNetCore.Authentication.JwtBearer 10.0.0, Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.0, and Scalar.AspNetCore 2.13.18. Identity runs on the EF Core in-memory provider, so there is no database to set up. Run dotnet run --project PermissionBasedAuth.Api and Scalar opens at /scalar/v1.

Three users are seeded, each engineered to pass some endpoints and fail others:

EmailPasswordRolePermissions
admin@codewithmukesh.comAdmin123!AdminView, Create, Edit, Delete
manager@codewithmukesh.comManager123!ManagerView, Create
user@codewithmukesh.comUser123!UserView

Defining the Permission Model

Permissions are just strings. What matters is that they are constants, so a typo becomes a compile error instead of a silent 403.

Entities/Permissions.cs
public static class Permissions
{
// Every permission claim uses this type.
public const string ClaimType = "permission";
// Policy names starting with this prefix get a policy generated on demand.
public const string Prefix = "Permissions.";
public static class Products
{
public const string View = "Permissions.Products.View";
public const string Create = "Permissions.Products.Create";
public const string Edit = "Permissions.Products.Edit";
public const string Delete = "Permissions.Products.Delete";
}
}

The Module.Action shape matters more than it looks. It gives you a prefix to match on, it sorts sensibly in an admin UI, and it lets you generate a whole module’s permissions from one string when you add an entity.

Permissions are granted to the role, not to the user. Identity already has a table for this: AspNetRoleClaims. Granting Products.Create to Manager once covers every manager you will ever hire, and revoking it covers them all just as fast.

Data/DbSeeder.cs
await GrantAsync(roleManager, Roles.Admin, Permissions.All);
await GrantAsync(roleManager, Roles.Manager,
[
Permissions.Products.View,
Permissions.Products.Create
]);
await GrantAsync(roleManager, Roles.User, [Permissions.Products.View]);

The Requirement and the Handler

One requirement type covers every permission in the system. The permission being demanded travels as data on the instance, not as a new class per rule.

Authorization/PermissionRequirement.cs
public class PermissionRequirement(string permission) : IAuthorizationRequirement
{
public string Permission { get; } = permission;
}

The handler answers one question: does this caller hold that permission?

Authorization/PermissionAuthorizationHandler.cs
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated is not true)
{
return;
}
var granted = settings.Value.Source == PermissionSource.Token
? ReadFromToken(context.User)
: await store.GetForRolesAsync(ReadRoles(context.User));
if (granted.Contains(requirement.Permission))
{
context.Succeed(requirement);
}
// No context.Fail() - another handler for the same requirement may still
// succeed. Fail() would veto them all.
}

Notice the handler reads permissions from one of two places. That switch is the most important design decision in this whole article, and I come back to it after the measurements. ReadRoles resolves roles through identity.RoleClaimType rather than a hard-coded claim name, which matters more than it looks; there is a section on why near the end.

Generating Policies Dynamically

Here is the piece that makes permissions practical. Without it, every permission needs its own AddPolicy call at startup, and adding a permission means editing Program.cs.

IAuthorizationPolicyProvider lets you build policies on demand. Any policy name starting with Permissions. gets a policy manufactured for it the first time it is requested.

Authorization/PermissionPolicyProvider.cs
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(Permissions.Prefix, StringComparison.OrdinalIgnoreCase))
{
var policy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
// Not about status codes - 401 vs 403 is decided by whether
// authentication succeeded, not by this call. It is here so the
// policy cannot be satisfied by an anonymous caller.
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallbackProvider.GetPolicyAsync(policyName);
}

Two things are non-negotiable here. First, Microsoft’s documentation is explicit that ASP.NET Core resolves exactly one policy provider, so anything yours does not recognise must be handed to DefaultAuthorizationPolicyProvider or every other named policy in your app stops resolving. Second, register it as a singleton:

Program.cs
// The policy provider MUST be a singleton - ASP.NET Core resolves exactly one.
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<PermissionStore>();

There is not a single AddPolicy call for permissions anywhere in the project.

Managing Permissions at Runtime

This is the payoff. Changing what a role can do is an API call.

Endpoints/AdminPermissionEndpoints.cs
var group = app.MapGroup("/api/admin/roles")
.RequireAuthorization(Permissions.Products.Edit);
group.MapPost("/{role}/permissions", async (
string role, PermissionRequest request, PermissionStore store) =>
{
// Validate against the known list so a typo cannot create a permission
// that nothing will ever demand.
if (!Permissions.All.Contains(request.Permission))
{
return Results.BadRequest($"Unknown permission '{request.Permission}'.");
}
return await store.GrantAsync(role, request.Permission)
? Results.Ok($"Granted '{request.Permission}' to '{role}'.")
: Results.NotFound($"Role '{role}' not found.");
});

PermissionStore writes through to Identity’s role claims and evicts its cache entry, so the next request sees the change. The cache matters because the handler hits this on every authorized request. In a real deployment that in-memory dictionary becomes HybridCache with a short TTL, so the lookup survives a restart and is shared across instances.

Authorization/PermissionStore.cs
await roleManager.AddClaimAsync(identityRole, new Claim(Permissions.ClaimType, permission));
// Evict so the next request reads the new grant.
_cache.TryRemove(role, out _);

Protecting Endpoints and Testing

Endpoints demand a permission by name. The permission string is the policy name, which is why no registration is needed.

Endpoints/ProductEndpoints.cs
group.MapGet("/", (ProductStore store) => Results.Ok(store.GetAll()))
.RequireAuthorization(Permissions.Products.View);
group.MapDelete("/{id:int}", (int id, ProductStore store) =>
store.Delete(id) ? Results.NoContent() : Results.NotFound())
.RequireAuthorization(Permissions.Products.Delete);

Running the requests in requests.http against the seeded users gives exactly the spread you want, and the difference between 401 and 403 is worth internalising if you have ever mixed them up:

CallerRequestStatus
anonymousDELETE /api/products/1401
userDELETE /api/products/1403
managerDELETE /api/products/1403
userGET /api/products200
managerPOST /api/products201
adminDELETE /api/products/1204

Anonymous gets a 401 because there is no identity to evaluate; the others get a 403 because they are authenticated and simply lack the permission. Full reference in HTTP status codes for ASP.NET Core APIs.

How Big Does a Token Get?

The obvious implementation is to stamp every permission into the JWT at login. No lookups, no cache, no database on the hot path. It is also the decision that quietly breaks production, and I have never seen anyone put numbers to it, so I built a probe into the sample.

GET /api/diagnostics/token-size mints tokens through the same signing path a real login uses and measures the bytes. These are measured, not estimated, on .NET 10 with HMAC-SHA256 and a 60 minute expiry:

PermissionsToken bytesAuthorization header bytes
0451475
10871895
502,4712,495
1004,4714,495
2008,4718,495
50020,47120,495

Roughly 40 bytes per permission, linear, no surprises. The surprise is where it lands.

Kestrel’s MaxRequestHeadersTotalSize defaults to 32,768 bytes. By that measure you get to 806 permissions before anything breaks, which sounds like plenty. But almost nobody exposes Kestrel directly. Put nginx in front, and its default is large_client_header_buffers 4 8k, where a single header field cannot exceed one 8,192 byte buffer or nginx returns 400 Bad Request. The Authorization header is one field.

So the real ceilings, measured against the exact byte counts above:

  • 192 permissions produces an 8,175 byte header and still fits nginx’s default buffer.
  • 193 permissions produces 8,213 bytes and nginx answers 400 Bad Request.
  • 806 permissions produces 32,735 bytes, the last count Kestrel’s default accepts.

Your reverse proxy fails roughly four times earlier than your application server. And it fails as a 400 from the proxy, which never reaches your logs, on a request that worked fine yesterday because someone was added to one more role. That is a genuinely miserable afternoon.

The Permission You Revoked Is Still Working

Token size is the problem people eventually notice. This is the one they do not.

A JWT is a signed snapshot. Once issued, its contents are fixed until it expires. If permissions live inside it, revoking a permission does nothing to tokens already in the wild.

I ran this against the sample with Permissions:Source set to Token:

  • Manager posts a product. 201 Created.
  • Admin revokes Permissions.Products.Create from the Manager role.
  • Manager posts another product with the same token. 201 Created. Still allowed.
  • Manager logs in again to get a fresh token, then posts. 403 Forbidden.

With a 60 minute expiry, that is up to an hour of access you believed you had removed. If you are revoking because someone left the company or an account was compromised, an hour is not an acceptable answer.

Now the same sequence with Permissions:Source set to Lookup, which is the sample’s default:

  • Manager posts a product. 201 Created.
  • Admin revokes Permissions.Products.Create.
  • Manager posts with the same token. 403 Forbidden. Immediately.
  • Manager still holds View, so GET /api/products returns 200.

Same token, same endpoint, opposite outcome. The only difference is where the handler read permissions from.

If you have a reason to keep permissions in the token, you need to buy back revocation some other way: short-lived access tokens paired with refresh token rotation so the window is minutes rather than an hour, IClaimsTransformation to re-hydrate permissions per request, or a permission version stamp in the token that you compare against the user’s current version and reject on mismatch.

Where Should Permissions Live?

That gives three real strategies. This matrix is about permission delivery specifically, and it is a different question from the roles versus claims versus policies choice I covered in the policy article.

StrategyToken sizeCost per requestRevocation takes effectGood fit
In the tokenGrows ~40 bytes per permissionZeroWhen the token expiresFew permissions, short expiry, no proxy in front
Lookup per requestConstantOne queryNext requestSmall scale, or auditing every check
Cached lookupConstantRoughly zero on cache hitNext request after evictionAlmost everything

My take: put roles in the token and look permissions up behind a cache. Roles are few, stable, and cheap to carry. Permissions are many, volatile, and the thing you most need to revoke in a hurry. Splitting them along that line keeps tokens flat no matter how many modules you add, and it makes a revoke take effect on the next request instead of on the next login.

The objection is the lookup cost, and it is mostly imagined. You are resolving a small set of strings keyed by role, not by user, so the cache hit rate is close to perfect. Three roles means three cache entries no matter how many users you have.

The strategy I would actively avoid is permissions in the token at any real scale. It looks like the simplest option on day one and it is the only one of the three with two independent failure modes waiting for you.

One more honest question: should you build this at all? Application frameworks like ABP ship a permission system with a management UI already wired up, and there are standalone NuGet packages that do the same. If you are starting greenfield and are happy adopting the framework wholesale, take the shortcut. I build it by hand when permissions have to sit inside an existing codebase that is not going to adopt a framework for one feature, and because the whole thing is about 150 lines that I would rather own than debug through an abstraction. Either way, know which of the three delivery strategies your chosen library uses, because that decision is made for you and it is the one that bites.

Why Are My Permission Checks Failing?

Both of these were in my 2021 code. Both fail silently, which is what makes them worth the space.

Every check returns 403 and you cannot see why. The original handler filtered claims like this:

// 2021 code - works on cookies, silently denies everything on JWT
var permissions = context.User.Claims.Where(x =>
x.Type == "Permission" &&
x.Value == requirement.Permission &&
x.Issuer == "LOCAL AUTHORITY");

LOCAL AUTHORITY is the value of ClaimsIdentity.DefaultIssuer, the issuer a Claim gets when none is supplied. That is what Identity stamps on claims it materialises locally from the store, which is what happens under cookie authentication. Claims parsed out of a JWT carry your token issuer instead. I confirmed this against the sample: with bearer authentication, every claim on the principal came back with issuer set to https://codewithmukesh.com. Not one carried LOCAL AUTHORITY. So that filter matches zero claims, every check fails, and everybody gets a 403 with nothing in the logs. Drop the issuer condition. If you need to trust only certain issuers, validate that on the token, not on individual claims.

Endpoints you never protected start returning 401. The original policy provider ended like this:

// Wrong: the FALLBACK slot is being handed the DEFAULT policy
public Task<AuthorizationPolicy> GetFallbackPolicyAsync() =>
FallbackPolicyProvider.GetDefaultPolicyAsync();

The default policy is what [Authorize] means with no policy named, and it requires an authenticated user. The fallback policy is what the authorization middleware applies when an endpoint specifies no policy at all. Returning the first from the second locks down endpoints that never asked for anything. I verified the difference on an endpoint declaring no authorization: returning GetFallbackPolicyAsync() gave an anonymous caller 200 OK, and returning GetDefaultPolicyAsync() gave the same caller 401. One line, same app. Return the fallback:

Authorization/PermissionPolicyProvider.cs
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() =>
_fallbackProvider.GetFallbackPolicyAsync();

Roles resolve as empty even though the token clearly has them. The role-mapping problem again: with MapInboundClaims = false and RoleClaimType = "role", the principal carries short role claims and ClaimTypes.Role finds nothing. Read identity.RoleClaimType instead of hard-coding either name. The sample exposes GET /api/diagnostics/claims for exactly this, dumping every claim type, value, and issuer on the current caller.

Key Takeaways

  • Permissions decouple the endpoint from the org chart. The endpoint demands a capability; who holds it becomes data, not code.
  • Grant permissions to roles, not users. Identity’s AspNetRoleClaims already exists for this, and one grant covers everyone in the role.
  • IAuthorizationPolicyProvider is what makes it practical. Register it as a singleton and always defer unknown names to the default provider, because ASP.NET Core resolves exactly one.
  • Measured: about 40 bytes per permission in the token. 193 permissions breaks nginx’s default 8 KB header buffer; 807 breaks Kestrel’s 32 KB default.
  • Permissions in the token cannot be revoked. A revoked permission keeps working until the token expires, which I reproduced as a 201 that should have been a 403.
  • Roles in the token, permissions behind a cached lookup. Flat token size, and revocation lands on the very next request.
What is permission-based authorization in ASP.NET Core?

Permission-based authorization is a model where each endpoint requires a named permission such as Permissions.Products.Delete instead of a role name. The mapping between roles and permissions is stored as data, usually as claims on the role, so an administrator can grant or revoke access at runtime without a code change or a redeployment.

What is the difference between role-based and permission-based authorization?

Role-based authorization checks whether a user belongs to a group, and the group name is compiled into your endpoints. Permission-based authorization checks whether the user holds a specific capability, and the mapping from roles to capabilities lives in the database, so it can change without a deployment. Roles are fine for up to about five stable groups; permissions scale further.

Where should I store permissions in ASP.NET Core?

Store them as claims on the role, in the AspNetRoleClaims table that ASP.NET Core Identity already provides. Granting a permission to a role covers every user in that role at once, and revoking it removes access from all of them just as quickly. Storing permissions per user means updating many rows for a single policy change.

Should I put permissions inside the JWT?

Usually not. Permissions in the token add roughly 40 bytes each, and a revoked permission keeps working until the token expires. Put roles in the token instead, since a user has only a handful, and resolve permissions from a cached lookup per request. Token size stays flat and revocation takes effect on the next request.

How many permissions can a JWT hold before it breaks?

Measured on .NET 10 with HMAC-SHA256, about 40 bytes per permission. At 193 permissions the Authorization header reaches 8,213 bytes and exceeds the 8,192 byte buffer that nginx allows for a single header line by default. Kestrel's own default limit of 32,768 bytes is not reached until around 807 permissions, so a reverse proxy typically fails long before the application server does.

Why does my permission check always return 403?

The most common cause is filtering claims by issuer. Code that requires the claim issuer to equal LOCAL AUTHORITY works under cookie authentication, because that is what Identity stamps on locally materialised claims, but claims parsed from a JWT carry the token issuer instead. The filter then matches nothing and every check fails. Remove the issuer condition from the handler.

How do I revoke a permission immediately in ASP.NET Core?

Resolve permissions from a store on each request rather than reading them from the token, and evict the cached entry for that role when the grant changes. The next request then sees the new state. If permissions must live in the token, shorten the token lifetime and pair it with refresh token rotation, or add a permission version stamp that you compare on each request.

Summary

Permission-based authorization is not a separate system. It is policy-based authorization with permissions stored as role claims and a provider that builds policies on demand, which together move access decisions out of your code and into data an admin can edit. Full source, including the diagnostics probe behind the measurements, is in the GitHub repository.

If you take one thing from this rebuild, make it the delivery decision. The pattern is well-trodden and the code is not hard. What separates an implementation that survives production is whether permissions ride in the token. Keep roles in the token, resolve permissions behind a cache, and both failures above stop being possible.

This closes the security module of my free .NET Web API Zero to Hero course, after JWT authentication, refresh tokens, and the three authorization articles. It pairs with rate limiting and API key authentication, while custom user management covers the admin surface behind a permissions screen. “Roles versus permissions” is also a stock .NET Web API interview question you can now answer with numbers.

Read next

Policy-Based Authorization in ASP.NET Core

The prerequisite for this article - requirements, handlers, and the decision matrix for roles versus claims versus policies.

Read next

Refresh Tokens in ASP.NET Core

The fix if you must keep permissions in the token - short-lived access tokens with rotation shrink the stale-permission window to minutes.

Read next

HybridCache in ASP.NET Core

What the in-memory permission cache in this sample should become in production - shared across instances and surviving restarts.

Happy Coding :)

Source code Open on GitHub

Grab the source code.

Get the full implementation. Drop your email for instant access, or skip straight to GitHub.

Skip - go straight to GitHub
View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Newsletter

stay ahead in .NET

One email every Tuesday at 7 PM IST. One topic, deep. The week's articles. No filler.

Tutorials Architecture DevOps AI
Join 9,735 developers · Delivered every Tuesday
Privacy notice 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →