Authorization in ASP.NET Core Web API: Roles, Policies and Resource-Based Authorization
We start with [Authorize] and FallbackPolicy, then move to roles, policies, our own requirement and handler, and resource-based authorization. We see the RoleClaimType trap and 401 vs 403 with curl.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
In the previous post we taught the API who is talking: a user who logs in gets a signed JWT and shows it on every request. But a valid token does not mean "you can do anything". In this post we move on to the second gate, authorization: who is allowed to do what?
We continue with the MapInboundClaims = false setting from the previous post, so the claim names in the token reach User unchanged. Ada's token clearly says "role": "Admin". Ada sends a request with her valid token to the DELETE api/products/2 endpoint, which is protected with [Authorize(Roles = "Admin")]. What do you think comes back: 204, 401 or 403?
Users, Roles and the Claims in the Token
To make the example richer we have four users. Ada is an admin, Alan and Grace are sellers, and Margaret is a customer. Alan has been a seller since 2024, Grace started two weeks ago:
Add(new AppUser { Id = 1, Email = "ada@example.com", DisplayName = "Ada Lovelace", Roles = new[] { "Admin" } });
Add(new AppUser { Id = 2, Email = "alan@example.com", DisplayName = "Alan Turing", Roles = new[] { "Seller" }, SellerSince = new DateTime(2024, 3, 1) });
Add(new AppUser { Id = 3, Email = "grace@example.com", DisplayName = "Grace Hopper", Roles = new[] { "Seller" }, SellerSince = new DateTime(2026, 10, 20) });
Add(new AppUser { Id = 4, Email = "margaret@example.com", DisplayName = "Margaret Hamilton", Roles = new[] { "Customer" } });
TokenService now also writes the roles and the seller start date into the token:
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim("name", user.DisplayName)
};
// Each role becomes a separate "role" claim
claims.AddRange(user.Roles.Select(role => new Claim("role", role)));
if (user.SellerSince is not null)
{
claims.Add(new Claim("seller_since", user.SellerSince.Value.ToString("yyyy-MM-dd")));
}
When we decode Grace's token, the payload looks like this:
{
"sub": "3",
"name": "Grace Hopper",
"role": "Seller",
"seller_since": "2026-10-20",
"aud": "ProductCatalog.Clients",
"exp": 1793742460,
"iss": "ProductCatalog.Api",
"iat": 1793741560,
"nbf": 1793741560
}
A role is just another claim in the end. All of authorization comes down to looking at these claims and making a decision; what differs is where and how that decision is written.
RoleClaimType: Tell It Where to Look for the Role
The answer to the guess: 403 Forbidden. Ada's token is valid and her role is in the token, yet the API does not see her as an admin. When I wrote the me endpoint to return User.Identity.Name and User.IsInRole("Admin"), the problem showed itself:
{"name":null,"isAdmin":false,"isSeller":false}
When IsInRole is called, ClaimsIdentity learns which claim type to look at from its RoleClaimType property. The default is ClaimTypes.Role, that is http://schemas.microsoft.com/ws/2008/06/identity/claims/role. While MapInboundClaims was on, JwtBearer translated the role claim into this long name, so everything just worked; turning the mapping off removed that translation as well. The fix is to tell the handler the right names:
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwt.Issuer,
ValidateAudience = true,
ValidAudience = jwt.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
// User.Identity.Name and User.IsInRole look at these claims
NameClaimType = "name",
RoleClaimType = "role"
};
});
The same request now returns {"name":"Ada Lovelace","isAdmin":true,"isSeller":false} and the DELETE gives 204. I got exactly the same result on .NET 6 and on .NET 10.
[Authorize], [AllowAnonymous] and FallbackPolicy
The [Authorize] attribute protects an action or a controller. [AllowAnonymous] removes the protection and always wins. Instead of writing these one by one, it is safer to flip the rule: every endpoint is protected by default, and you mark the open ones. That is what FallbackPolicy does:
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
FallbackPolicy applies to endpoints that carry no authorization attribute at all. The Me action in AuthController has no [Authorize] on it, yet a request without a token gets a 401. The GetAll action in ProductsController carries [AllowAnonymous], so it is open to everyone. Do not confuse this with DefaultPolicy: DefaultPolicy is the policy used when you write [Authorize] without parameters.
We can also see the difference between 401 and 403 clearly here. A POST api/products request without a token returns 401: we do not know who you are. The same request with Margaret's token returns 403: we know who you are, but a customer cannot add products. The 403 response has an empty body and no WWW-Authenticate header, because there is no identity problem to solve.
Authorizing with Roles
The simplest way is to write the role in the attribute:
[HttpDelete("{id:int}")]
[Authorize(Roles = "Admin")]
public IActionResult Delete(int id)
With Alan's token this endpoint returns 403, with Ada's it returns 204. Two rules are all you need to know. The comma in [Authorize(Roles = "Admin,Seller")] means "or": Alan could get into that endpoint. If you stack two separate [Authorize(Roles = ...)] attributes, it becomes "and": neither Ada nor Alan could get into an endpoint carrying both an Admin and a Seller attribute, because neither of them has both roles.
Roles do the job in small projects, but as the rules grow, "Admin,Seller" strings scattered across controllers make maintenance hard. The answer to "who can manage products?" should live in one place. That is exactly what policies are for.
Policies
A policy is one or more requirements gathered under a name. You define it once in Program.cs and only mention its name in the controller:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageProducts", policy =>
policy.RequireRole("Admin", "Seller"));
// Every requirement in a policy must be satisfied
options.AddPolicy("ExperiencedSeller", policy =>
policy.RequireRole("Seller")
.AddRequirements(new MinimumSellerTenureRequirement(30)));
options.AddPolicy("ProductOwner", policy =>
policy.AddRequirements(new ProductOwnerRequirement()));
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
Using it in the controller:
[HttpPost]
[Authorize(Policy = "CanManageProducts")]
public ActionResult<Product> Create(ProductRequest request)
Besides RequireRole there are ready-made methods such as RequireClaim("seller_since"), which requires a claim to exist, or RequireClaim("role", "Seller"), which requires a specific value. When the rule is too specific to express with these, you write your own requirement.
Since the policy name is a string, a typo does not trip the compiler. When I deliberately wrote "CanManageProduct" on an action, the request returned 500:
System.InvalidOperationException: The AuthorizationPolicy named: 'CanManageProduct' was not found.
Keeping the names as const string values in a static class moves this error to compile time.
Requirement and Handler
Our business rule: to apply a discount you must have been a seller for at least 30 days. We write it as a requirement and a handler that evaluates it:
using System.Globalization;
using Microsoft.AspNetCore.Authorization;
namespace ProductCatalog.Auth;
public class MinimumSellerTenureRequirement : IAuthorizationRequirement
{
public MinimumSellerTenureRequirement(int days)
{
Days = days;
}
public int Days { get; }
}
public class MinimumSellerTenureHandler : AuthorizationHandler<MinimumSellerTenureRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumSellerTenureRequirement requirement)
{
var claim = context.User.FindFirst("seller_since");
if (claim is not null &&
DateTime.TryParseExact(claim.Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var since) &&
since.AddDays(requirement.Days) <= DateTime.UtcNow)
{
context.Succeed(requirement);
}
// If Succeed is not called, the requirement counts as not satisfied
return Task.CompletedTask;
}
}
The requirement only carries data (how many days?); the decision lives in the handler. Do not forget to register the handlers in DI:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumSellerTenureHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ProductOwnerHandler>();
The results for a POST api/products/1/discount request: Alan gets 200 and the keyboard's price drops from 1450 to 1350. Grace gets 403, because her seller tenure has not reached 30 days. Ada gets 403 as well. Being an admin does not help here, because the ExperiencedSeller policy requires both the Seller role and the tenure condition, and every requirement in a policy must be satisfied. If you want to let admins in too, add a context.User.IsInRole("Admin") check to the handler and remove the role condition from the policy.
Resource-Based Authorization
Some decisions cannot be made by looking at the user alone. For the rule "a seller can only update their own product" you also need to see the product, and the product only becomes known inside the action, once it is read from the database. An attribute is not enough at this point; we use the generic AuthorizationHandler<TRequirement, TResource>, which hands the resource to the handler as well:
using Microsoft.AspNetCore.Authorization;
using ProductCatalog.Products;
namespace ProductCatalog.Auth;
public class ProductOwnerRequirement : IAuthorizationRequirement
{
}
public class ProductOwnerHandler : AuthorizationHandler<ProductOwnerRequirement, Product>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProductOwnerRequirement requirement,
Product resource)
{
// An admin can update any product, a seller only their own
var userId = context.User.FindFirst("sub")?.Value;
if (context.User.IsInRole("Admin") || userId == resource.SellerId.ToString())
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
In the action we call IAuthorizationService ourselves:
[HttpPut("{id:int}")]
[Authorize(Policy = "CanManageProducts")]
public async Task<IActionResult> Update(int id, ProductRequest request)
{
var product = _store.Find(id);
if (product is null)
{
return NotFound();
}
// We decide only after we have seen the product
var result = await _authorizationService.AuthorizeAsync(User, product, "ProductOwner");
if (!result.Succeeded)
{
return Forbid();
}
product.Name = request.Name;
product.Price = request.Price;
return NoContent();
}
Product 1 belongs to Alan. When Grace tries to update it she gets 403, when Alan does he gets 204. Ada can update Grace's mouse, because the handler allows admins. Forbid() produces a 403 for an authenticated user, and the [Authorize(Policy = "CanManageProducts")] attribute turns requests without a token away with a 401 before they ever reach the action.
Common Pitfalls
Not matching the name of the role claim. The opening example: the claim is in the token, but if RoleClaimType expects a different name, IsInRole returns false and you get a 403.
Mixing up 401 and 403. No identity means 401; an identity without permission means 403. On a 401 the client tries to log in again; on a 403 that makes no sense.
Doing the resource check only in the list. Filtering the product list by seller does not stop someone from sending PUT api/products/1 by hand. Repeat the resource-based check on every endpoint that changes data.
Forgetting to register the handler in DI. No handler satisfies a requirement that has none registered; the rule silently returns 403 to everyone.
Leaving permission checks to the client. Hiding a button is a user experience decision, not security. The real check must always happen in the API.
A Small Experiment
★☆☆ Gather the policy names as const string values in a static class named Policies and switch every [Authorize(Policy = ...)] usage to these constants.
★★☆ Change the ExperiencedSeller policy so that admins can pass it too. Verify with curl that Ada gets 200 while Grace still gets 403.
★★★ Make the DELETE action resource-based as well: a seller can delete their own product, but only if its price is under 1000; an admin can delete any product.
Who is allowed to do what is now defined in one place. The next question is a different one: when you call the API from a frontend running in the browser, why is the request blocked even though the token is correct? In the next post we cover CORS, HTTPS and HSTS, security headers and secret management.