Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated October 30, 2025

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

Request
Authentication
Authorization
Allow or Deny

Steps

1

Request

Client sends token

2

Authentication

Who are you?

3

Authorization

What may you do?

4

Allow or Deny

Run or reject

Authentication answers who you are. Authorization answers what you may do.

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 thisIt meansSimple test
401 UnauthorizedWe do not know who you areWould logging in again fix it? Then 401
403 ForbiddenWe know you, but you lack permissionLogging 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.

A full login then call flow with roles inside the token.

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.

How RequireRole reads: one call is OR, chained calls are AND.

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

Role
Permissions
Token
Endpoint

Steps

1

Role

Manager

2

Permissions

users:delete granted

3

Token

permission claim added

4

Endpoint

RequireClaim permission

Roles own permissions. Tokens carry permissions. Endpoints check permissions.

Here is how the two approaches compare. Neither is "wrong" — they fit different sizes of app.

AspectRole-basedPermission-based
Setup effortLow, quick to startHigher, needs a permission table
Endpoint checkRequireRole("Admin")RequireClaim("permission", "users:delete")
Adding a new roleEdit many endpointsJust map permissions to the new role
Best forSmall to medium appsLarge apps with many fine-grained actions
ReadabilityVery clear at a glanceClear 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.

The decision path: missing token gives 401, wrong role gives 403.

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:

CallerToken stateExpected for a delete
AnonymousNo token401 Unauthorized
MemberValid, wrong role403 Forbidden
AdminValid, right role204 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.Role claims, 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. Chaining RequireRole("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 UseAuthentication before UseAuthorization.

References and further reading

Related Posts