Building Secure APIs with Role-Based Access Control in ASP.NET Core
Learn role-based access control (RBAC) in ASP.NET Core. Add roles to JWT tokens, guard endpoints with policies, and return correct 401 and 403 codes, with diagrams and code.
A school with different keys
Think about a big school. The principal has a master key that opens every room. A teacher has a key for classrooms and the staff room. A student can only open their own locker. Nobody decides this person by person. Instead, the school decides what each role can open, and then hands you a key based on your role.
This is exactly what Role-Based Access Control, or RBAC, does for your API. You do not give powers to each user one at a time. You give powers to a role like Admin, Manager, or Member. Then you put each user into a role. The role decides what doors open.
In ASP.NET Core, the "key" is a JWT token. The token carries the user's roles inside it. When a request arrives, the server reads those roles and decides: open the door, or politely say no. Let us build this the secure way.
Two questions every secure API asks
Before any protected action runs, your API asks two separate questions. Keeping them separate is the heart of good security.
The two security questions
Steps
Request
Client sends token
Authentication
Who are you?
Authorization
What may you do?
Allow or Deny
Run or reject
Authentication checks who you are. Is your token real and not expired? If this fails, the answer is 401 Unauthorized.
Authorization checks what you are allowed to do. You are a real, logged-in user — but does your role have permission for this action? If this fails, the answer is 403 Forbidden.
These two words sound alike, so people mix them up. Here is the simple rule to remember.
| You see this | It means | Simple test |
|---|---|---|
| 401 Unauthorized | We do not know who you are | Would logging in again fix it? Then 401 |
| 403 Forbidden | We know you, but you lack permission | Logging in again will not help. Then 403 |
A common bug is returning 401 when you really mean 403. That confuses clients. A retry of the login will never fix a permission problem, so be honest and send 403.
How RBAC flows end to end
Let us follow one request from login to a protected endpoint. The token is the thread that ties it all together.
The important idea: the roles are decided once at login and baked into the token. Every later request just reads them. The protected route never needs to call the database again to know your role. That makes RBAC fast.
Step 1: Put roles inside the token
When a user logs in, you load their roles from your database and add them to the token as role claims. ASP.NET Core understands ClaimTypes.Role out of the box. One claim per role.
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
public string CreateToken(User user, IEnumerable<string> roles)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email)
};
// One role claim per role. ASP.NET Core reads these automatically.
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_settings.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}Notice the token expires in 15 minutes. Keep access tokens short. If a role changes in your database, the old token still carries the old role until it expires. Short tokens keep that window small. For longer sessions, pair this with refresh tokens.
Step 2: Wire up authentication and authorization
In Program.cs you tell ASP.NET Core to validate JWT tokens and to know about your roles. The order matters: UseAuthentication must come before UseAuthorization.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!))
};
});
// Define named policies once, reuse everywhere.
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"))
.AddPolicy("StaffArea", policy => policy.RequireRole("Admin", "Manager"))
.AddPolicy("CanDeleteUsers", policy =>
policy.RequireClaim("permission", "users:delete"));
var app = builder.Build();
app.UseAuthentication(); // Who are you? (must be first)
app.UseAuthorization(); // What may you do?
app.Run();Look closely at the two role policies. StaffArea uses RequireRole("Admin", "Manager"). That means the user needs any one of those roles. This is an OR check. If you wanted an AND check — the user must hold both roles — you would chain the calls: policy.RequireRole("Admin").RequireRole("Manager"). This small detail trips up many people, so keep it in mind.
Step 3: Guard your endpoints
Now protect routes. You can do this two ways. The first uses the role name directly on the endpoint. The second uses a named policy. Both work for Minimal APIs and controllers.
// Minimal API: anyone with a valid token may read.
app.MapGet("/api/products", () => GetProducts())
.RequireAuthorization();
// Only Admins may delete. Uses the policy from Program.cs.
app.MapDelete("/api/products/{id}", (int id) => DeleteProduct(id))
.RequireAuthorization("AdminOnly");
// Admins or Managers may view reports.
app.MapGet("/api/reports", () => GetReports())
.RequireAuthorization("StaffArea");For controllers, the [Authorize] attribute does the same job. You can put a broad rule on the whole controller and a tighter rule on one action.
[ApiController]
[Route("api/[controller]")]
[Authorize] // every action needs a valid token
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult GetAll() => Ok(_service.GetUsers());
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")] // tighter rule for this one action
public IActionResult Delete(int id)
{
_service.Delete(id);
return NoContent();
}
}This layered style is nice. The class-level [Authorize] says "you must be logged in." The action-level [Authorize(Roles = "Admin")] adds "and you must be an Admin for this one." ASP.NET Core combines them, so both must pass.
Roles versus permissions: pick the right tool
Roles are great for broad groups. But hardcoding role names all over your code gets messy. What happens when you add a new SeniorManager role that should also delete users? You would have to hunt through every endpoint and edit the role list. Painful.
A more flexible pattern is permission-based authorization. You define small, specific permissions like users:delete or reports:read. You assign permissions to roles in your database. At login, you add the permission claims to the token, not just role names. Then your endpoints check for a permission, never a role.
Permission-based RBAC
Steps
Role
Manager
Permissions
users:delete granted
Token
permission claim added
Endpoint
RequireClaim permission
Here is how the two approaches compare. Neither is "wrong" — they fit different sizes of app.
| Aspect | Role-based | Permission-based |
|---|---|---|
| Setup effort | Low, quick to start | Higher, needs a permission table |
| Endpoint check | RequireRole("Admin") | RequireClaim("permission", "users:delete") |
| Adding a new role | Edit many endpoints | Just map permissions to the new role |
| Best for | Small to medium apps | Large apps with many fine-grained actions |
| Readability | Very clear at a glance | Clear about what, not who |
A simple rule of thumb: start with roles. When you feel pain from editing the same endpoints again and again, move to permissions. You saw the CanDeleteUsers policy earlier — that policy already checks a permission claim, so your code is ready for either style.
Step 4: Make 401 and 403 behave correctly
By default ASP.NET Core already sends 401 when the token is missing or invalid, and 403 when the role check fails. You usually do not write that code yourself — the framework does it. But you should make sure the body of the response is helpful. A bare 403 with no message confuses front-end developers.
app.UseExceptionHandler();
app.UseStatusCodePages(async context =>
{
var response = context.HttpContext.Response;
if (response.StatusCode == StatusCodes.Status403Forbidden)
{
response.ContentType = "application/json";
await response.WriteAsJsonAsync(new
{
error = "forbidden",
message = "Your role does not allow this action."
});
}
});The state machine below shows how one request moves through the checks. Each failure exits with the correct code.
Common mistakes to avoid
These are the slip-ups that turn a "secure" API into a leaky one. Learn them now so you never ship them.
Trusting the client to send the role. Never read a role from a request header or query string. Roles must come only from the signed token that your own server created. A signed token cannot be faked without your secret key.
Putting secrets in the token payload. A JWT payload is only Base64-encoded, not encrypted. Anyone can read it. Put roles and an ID there, never passwords or private data.
Long-lived tokens. A token that lasts a week means a removed Admin keeps Admin powers for a week. Keep access tokens short and use refresh tokens for longer sessions.
Forgetting the middleware order. If UseAuthorization runs before UseAuthentication, the framework does not know who the user is yet, so every role check fails or behaves oddly. Authentication first, always.
Checking roles by hand inside the action. Writing if (user.IsInRole("Admin")) deep in your business code spreads security logic everywhere and is easy to forget. Let the [Authorize] attribute or a policy do it at the door.
Testing your RBAC rules
Security code is exactly the kind of code you want to test, because a mistake here is silent until someone abuses it. The good news is that RBAC rules are easy to test. You write small tests that call each endpoint with different roles and check the status code.
Think about the cases you care about for a delete endpoint. An anonymous caller with no token should get 401. A logged-in Member should get 403, because they are known but not allowed. A logged-in Admin should get 200 or 204, because they are allowed. Three tiny tests, and you have proven your most dangerous endpoint behaves correctly.
A simple integration test using WebApplicationFactory can mint a token with a chosen role, attach it to the request, and assert the response code. Run these tests on every build. If someone later loosens a policy by accident, a red test catches it before it ships. This habit costs a few minutes and saves you from the worst kind of bug — the one nobody notices until it is too late.
A handy table to keep beside you while writing those tests:
| Caller | Token state | Expected for a delete |
|---|---|---|
| Anonymous | No token | 401 Unauthorized |
| Member | Valid, wrong role | 403 Forbidden |
| Admin | Valid, right role | 204 No Content |
A note on third-party libraries
Some popular .NET libraries changed their licensing recently. MediatR and MassTransit are now commercially licensed for many uses, so check their terms before adding them to a new project. For plain RBAC you do not need either of them — everything shown here uses the built-in ASP.NET Core authentication and authorization packages, which are free and fully supported on .NET 10 (LTS) with C# 14. Keeping your security on the framework's own building blocks means fewer surprises and fewer bills.
Quick recap
- RBAC gives powers to roles, not to each user. Users join a role, and the role decides what doors open.
- Authentication asks who you are (fail = 401). Authorization asks what you may do (fail = 403). If a fresh login would fix it, it is 401.
- Roles travel inside the JWT token as
ClaimTypes.Roleclaims, decided once at login and read on every request. - Guard endpoints with
RequireAuthorization("PolicyName")in Minimal APIs or[Authorize(Roles = "Admin")]in controllers. RequireRole("A", "B")means A or B. ChainingRequireRole("A").RequireRole("B")means A and B.- Start with roles; move to permission-based policies (
RequireClaim) when editing the same endpoints gets painful. - Keep tokens short, never trust roles from the client, and always put
UseAuthenticationbeforeUseAuthorization.
References and further reading
- Role-based authorization in ASP.NET Core — Microsoft Learn
- Authentication and authorization in Minimal APIs — Microsoft Learn
- Configure JWT bearer authentication in ASP.NET Core — Microsoft Learn
- Building Secure APIs with Role-Based Access Control in ASP.NET Core — Milan Jovanović
- 401 vs 403: Auth vs Permissions — Logto blog
Related Posts
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
Refresh Tokens and Token Revocation in ASP.NET Core: A Beginner Guide
Learn refresh tokens and token revocation in ASP.NET Core with simple words, diagrams, and code. Short access tokens, rotation, reuse detection, and safe logout.
CORS in ASP.NET Core: A Comprehensive Guide
A simple, friendly guide to CORS in ASP.NET Core. Learn how the browser, preflight requests, and policies work, with clear diagrams, tables, and code.