ASP.NET Core Web API · Cross-Cutting

Role-Based Authorization

With JWT authentication in place, the ShopSmart API knows who is calling. Now it must decide what they may do: customers read the catalogue and their own orders, staff manage stock, and only admins change prices.

Intermediate 7 min Lesson 24 of 37Examples target .NET 9.
Before you start:JWT Authentication
0/37 in ASP.NET Core Web API
On this page · 7 min · Intermediate

In simple words#

Different wristband colours open different areas: blue for guests, gold for VIP, red for staff. Roles decide which endpoints a user may call.

[Authorize(Roles = "Admin")]
Only admins.
Claims
Facts in the token, e.g. role.
403 vs 401
Logged in but not allowed vs not logged in.

Learning objectives#

  • Put roles and other claims in tokens
  • Protect endpoints with roles and named policies
  • Require authentication everywhere with a fallback policy
  • Return 401 and 403 correctly

Explanation#

Roles travel as claims in the token ("role": "Admin"). Tell the JWT handler which claim holds roles (TokenValidationParameters.RoleClaimType = "role") so [Authorize(Roles = "Admin")] and User.IsInRole work.

Policies name a rule once: AddPolicy("ManageStock", p => p.RequireRole("Admin", "Staff")). Claims-based policies check other claims, e.g. RequireClaim("scope", "orders:write") for third-party access.

A fallback policy (RequireAuthenticatedUser) protects every endpoint that has no attribute — new endpoints are secure by default. Mark public ones with [AllowAnonymous].

No or invalid token → 401 Unauthorized. Valid token without the required role or claim → 403 Forbidden.

Resource ownership (“customers may only read their own orders”) is checked in code or with resource-based authorization, not with roles alone.

Syntax#

C# Illustrative
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("ManageStock", p => p.RequireRole("Admin", "Staff"))
    .AddPolicy("WriteOrders", p => p.RequireClaim("scope", "orders:write"))
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());

Code example: Roles, policies and a secure-by-default API#

C#Controllers/CatalogController.cs Illustrative
// Program.cs
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;


var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("demo-only-signing-key-change-me-at-least-32-bytes!"));
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                    // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(o =>
{
    o.MapInboundClaims = false;                                      // keep claim names as they are in the token
    o.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = "shopsmart", ValidAudience = "shopsmart-api", IssuerSigningKey = key,
        RoleClaimType = "role", NameClaimType = "name",
    };
});
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("ManageStock", p => p.RequireRole("Admin", "Staff"))
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());


var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5224");
await app.StartAsync();


string Token(string role) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
    Issuer = "shopsmart", Audience = "shopsmart-api", Expires = DateTime.UtcNow.AddMinutes(15),
    Subject = new ClaimsIdentity([new Claim("name", 
quot;demo-
{role.ToLowerInvariant()}"), new Claim("role", role)]),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), }); using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5224") }; foreach (var (who, method, path) in new[] { ("anonymous", "GET", "/api/catalog"), ("anonymous", "GET", "/api/orders"), ("Customer", "GET", "/api/orders"), ("Customer", "POST", "/api/catalog/1/stock"), ("Staff", "POST", "/api/catalog/1/stock"), ("Staff", "PUT", "/api/catalog/1/price"), ("Admin", "PUT", "/api/catalog/1/price"), }) { var request = new HttpRequestMessage(new HttpMethod(method), path); if (who != "anonymous") request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token(who)); var response = await http.SendAsync(request); Console.WriteLine(
quot;
{who,-9} {method,-4} {path,-22} {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}".TrimEnd());
} await app.StopAsync(); // Controllers/CatalogController.cs using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/catalog")] public class CatalogController : ControllerBase { [AllowAnonymous] // public, despite the fallback policy [HttpGet] public IActionResult Browse() => Ok(new[] { "Wireless Mouse", "USB-C Hub" }); [Authorize(Policy = "ManageStock")] [HttpPost("{id:int}/stock")] public IActionResult AddStock(int id) => Ok(new { id, stockedBy = User.Identity!.Name }); [Authorize(Roles = "Admin")] [HttpPut("{id:int}/price")] public IActionResult ChangePrice(int id) => Ok(new { id, changedBy = User.Identity!.Name }); } // Controllers/OrdersController.cs using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/orders")] public class OrdersController : ControllerBase // no attribute: the fallback policy applies { [HttpGet] public IActionResult Mine() => Ok(new { owner = User.Identity!.Name, orders = Array.Empty<int>() }); }
Expected output
anonymous GET  /api/catalog           200 ["Wireless Mouse","USB-C Hub"]
anonymous GET  /api/orders            401
Customer  GET  /api/orders            200 {"owner":"demo-customer","orders":[]}
Customer  POST /api/catalog/1/stock   403
Staff     POST /api/catalog/1/stock   200 {"id":1,"stockedBy":"demo-staff"}
Staff     PUT  /api/catalog/1/price   403
Admin     PUT  /api/catalog/1/price   200 {"id":1,"changedBy":"demo-admin"}

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. The catalogue is [AllowAnonymous], so anyone can browse it.
  2. OrdersController has no attribute; the fallback policy requires a signed-in user, so anonymous gets 401.
  3. The ManageStock policy allows Admin and Staff; a Customer token is valid but gets 403.
  4. Changing prices needs the Admin role: Staff gets 403, Admin gets 200.
  5. MapInboundClaims = false and RoleClaimType = "role" make the token’s short claim names work with roles and User.Identity.Name.

Real-world use case#

ShopSmart’s warehouse staff use the same API as customers but with Staff tokens; a third-party marketplace partner gets tokens with scope=orders:read only, enforced by a claim policy.

Common mistakes#

Forgetting the fallback policy

A new controller without [Authorize] is public by default. A fallback policy flips the default to “authenticated only”.

Role claim name mismatch

If the token uses "role" but the handler expects the long ClaimTypes.Role URI, every role check fails. Set RoleClaimType or keep mapping consistent.

Using roles for ownership

A Customer role does not mean “may read order 42”. Check the order’s owner in code.

Best practices#

  • Named policies for each permission
  • Secure-by-default fallback policy
  • [AllowAnonymous] only on truly public endpoints
  • Ownership checks for personal data

Remember this#

No token → 401. Wrong role → 403.

[Authorize(Roles)] for simple, policies for everything else.

Fallback policy = secure by default.

Practice exercise#

Your task: Add a policy "ReadReports" that requires the claim scope=reports:read, and use it on GET /api/reports.

Need a hint?
AddPolicy("ReadReports", p => p.RequireClaim("scope", "reports:read")) and [Authorize(Policy = "ReadReports")].
Show solution
C# Illustrative
// Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("ReadReports", p => p.RequireClaim("scope", "reports:read"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();


// Controllers/ReportsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;


[ApiController]
[Route("api/reports")]
public class ReportsController : ControllerBase
{
    [Authorize(Policy = "ReadReports")]
    [HttpGet]
    public IActionResult Get() => Ok(new { revenue = 125000 });
}

Mini quiz#

Role-Based Authorization quizMultiple choice Question 1 of 3

A valid Customer token calls an Admin-only endpoint. What is returned?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.