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.
On this page · 7 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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>() });
}
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#
- The catalogue is [AllowAnonymous], so anyone can browse it.
- OrdersController has no attribute; the fallback policy requires a signed-in user, so anonymous gets 401.
- The ManageStock policy allows Admin and Staff; a Customer token is valid but gets 403.
- Changing prices needs the Admin role: Staff gets 403, Admin gets 200.
- 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#
A new controller without [Authorize] is public by default. A fallback policy flips the default to “authenticated only”.
If the token uses "role" but the handler expects the long ClaimTypes.Role URI, every role check fails. Set RoleClaimType or keep mapping consistent.
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?
Show solution
// 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#
A valid Customer token calls an Admin-only endpoint. What is returned?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.