Advanced Track
Role-Based, Policy-Based & Claims-Based Security
By the end of this lesson you'll be able to tell authentication from authorization, and lock down an ASP.NET Core app three ways — by role, by policy, and by claim — choosing the right model for each rule and defaulting to deny so you never leak an endpoint by accident.
What You'll Learn
- Separate authentication (who you are) from authorization (what you may do)
- Authorize by role with [Authorize(Roles = "...")] and roles.Contains
- Build named policies with AddAuthorization(o => o.AddPolicy(...))
- Decide access from claims — facts on the identity — like age or tier
- Read the signed-in user from a ClaimsPrincipal in a controller
- Apply least privilege and a default-deny fallback policy
💡 Real-World Analogy
Think of getting around a secure office building. A role badge (role-based) opens whole categories of door at once — "Staff", "Manager" — simple, but everyone with that badge gets the same access. A keycard policy (policy-based) is a named rule the reader enforces — "Staff and fire-trained and after 6pm" — a reusable combination of conditions. Personal attributes (claims-based) are the facts printed on the badge itself — your clearance level, your department, your date of birth — and the door decides from those facts, not from your job title. Most real buildings use all three: a badge for the easy cases, policies for the combinations, and the printed facts when a job title is too blunt an instrument.
The Three Models at a Glance
| Model | Decides on | Typical code | Best for |
|---|---|---|---|
| RBAC (role) | A role label on the user | [Authorize(Roles="Admin")] | Coarse, stable groups |
| Policy | A named bundle of rules | [Authorize(Policy="Premium")] | Combining several conditions |
| Claims | A fact (type, value) | RequireClaim("age","18+") | Fine-grained, data-driven rules |
In ASP.NET Core these aren't rivals — a role is just a claim of type role, and a policy is the general mechanism that role checks and claim checks both run through. Reach for the simplest model that expresses the rule.
1. Authentication vs Authorization
These two words look alike and are constantly confused, but they answer different questions. Authentication (authn) proves who you are — logging in, validating a token. Authorization (authz) decides what you're allowed to do once your identity is known. A user can be perfectly authenticated and still be forbidden: that's a 403, not a 401. Authorization always comes second. Read this worked example, run it, then you'll write the role check yourself.
Worked example: authn proves identity, authz grants access
Run it — Alice is authenticated but still forbidden from the admin area.
using System;
using System.Collections.Generic;
// Authentication = WHO are you? (proving identity)
// Authorization = WHAT may you do? (deciding access)
// They are two separate steps. You authenticate FIRST, then authorize.
class User
{
public string Name { get; }
public bool IsAuthenticated { get; } // set by the login/authn step
public List<string> Roles { get; } // assigned to the identity
public User(string name, bool isAuthenticated, List<string> roles)
{
...2. Role-Based Authorization (RBAC)
Role-based access control is the simplest model: each user carries a list of role labels, and you grant access when a required label is present. At its core it's just roles.Contains("Admin"). In ASP.NET Core you rarely write that if yourself — you declare it with [Authorize(Roles = "...")] and the framework enforces it. Comma-separated roles mean OR (any one matches); stacking the attribute means AND (all required). First, finish the plain check below.
Your turn. The program below authorizes by role — fill in the two ___ blanks using the hints, then run it.
🎯 Your turn: authorize by role
Use roles.Contains(...) to decide access, then check Bob gets in.
using System;
using System.Collections.Generic;
class User
{
public string Name { get; }
public List<string> Roles { get; }
public User(string name, List<string> roles) { Name = name; Roles = roles; }
}
class Program
{
static void Main()
{
// 🎯 YOUR TURN — authorize by ROLE. Fill in each ___ then run it.
var user = new User("Bob", new List<string> { "Manager", "Admin" });
// 1) Bob is authorized only if his Roles list CONTAINS "Admin".
boo
...Here's how the same check looks in a real ASP.NET Core controller. Notice there's no if at all — the [Authorize] attribute is the rule, and the framework returns 403 for you when it isn't met.
Worked example: [Authorize(Roles = ...)] in ASP.NET Core
OR with commas, AND by stacking the attribute — the framework does the check.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
// In ASP.NET Core the framework does the role check FOR you, declaratively,
// with the [Authorize] attribute. No 'if (Roles.Contains(...))' in your code.
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
// Only an authenticated user whose identity has the "Admin" role gets in.
[HttpGet("dashboard")]
[Authorize(Roles = "Admin")]
public IActionResult GetDashboard(
...3. Claims-Based Authorization
Roles are blunt: "is this person an Admin?" tells you nothing about why. A claim is a single fact about the user — a (type, value) pair like ("age", "21") or ("email_verified", "true") — and claims-based authorization decides access from those facts directly. This is more honest: instead of inventing an "Over18" role, you check the age claim. A policy here is simply a predicate over the user's claims. Read the worked example, then write your own age check.
Worked example: a claims policy (age >= 18)
The identity is a bag of (type, value) facts; the policy is a predicate over them.
using System;
using System.Collections.Generic;
using System.Linq;
// A CLAIM is a single fact about a user: a (type, value) pair.
// "name" = "Alice", "age" = "21", "email_verified" = "true".
// Claims-based authorization decides access by the FACTS, not by a role label.
class Program
{
static void Main()
{
// The identity is just a bag of claims (type, value).
var claims = new List<(string Type, string Value)>
{
("name", "Alice"),
("a
...Now you try. Authorize by a claim rather than a role: read the age fact and apply an 18+ policy. Fill in the two ___ blanks:
🎯 Your turn: authorize by a claim
Find the age claim and require age >= 18; Sam (16) should be denied.
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — authorize by a CLAIM, not a role. Fill in each ___.
var claims = new List<(string Type, string Value)>
{
("name", "Sam"),
("age", "16")
};
// 1) Pull out the value of the "age" claim by its Type.
var ageClaim = claims.FirstOrDefault(c => c.Type == ___); // 👉 "age" (in quotes)
// 2) T
...4. Policy-Based Authorization in ASP.NET Core
Policy-based authorization is the model the others run through. You register a named policy once with AddAuthorization(o => o.AddPolicy(...)), combine as many requirements as you like (roles, claims, custom logic), and reuse the name with [Authorize(Policy = "...")]. Inside the app, the signed-in user is a ClaimsPrincipal — you read facts off it with FindFirst, HasClaim, and IsInRole. Crucially, a FallbackPolicy makes the whole app default-deny: every endpoint needs a login unless you explicitly opt out.
Worked example: AddPolicy, FallbackPolicy & ClaimsPrincipal
Register reusable policies, default-deny with a fallback, and read the user.
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
// A POLICY bundles one or more requirements behind a name you reuse everywhere.
// Register them once at startup, then refer to them by name on endpoints.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
// Claims-based policy: the identity must carry email_verified = "true".
options.AddPolicy("EmailVerified", policy =>
policy.RequireClaim("emai
...🔎 Deep Dive: the Principle of Least Privilege
Least privilege means every user, token, and service gets the minimum access needed to do its job — and nothing more. A reporting service that only reads data should never hold write credentials; a support agent who resets passwords shouldn't also be able to delete accounts. The blast radius of a stolen token or a compromised account is exactly the privilege it carried.
Two habits make this concrete in C#. First, default-deny: set a FallbackPolicy requiring an authenticated user, then add [AllowAnonymous] only to the genuinely public endpoints. A new endpoint is then locked by default — you can't forget to protect it. Second, authorize by capability, not by person: a policy named "CanRefund" survives reorganisations and new roles, whereas Roles = "SeniorManager" rots the moment the org chart changes.
// Default-deny once, opt out explicitly per public endpoint:
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build();
[AllowAnonymous] // the ONLY way an endpoint becomes public
public IActionResult Health() => Ok("ok");Pro Tips
- 💡 Name policies after capabilities, not roles:
"CanApproveRefund"outlives every reorg;Roles = "Manager"doesn't. - 💡 Turn on default-deny: a
FallbackPolicyplus[AllowAnonymous]means new endpoints are protected automatically. - 💡 Never trust a claim the client can set: only authorize on claims your server issued and signed (e.g. inside a validated JWT).
- 💡 Use resource-based checks for ownership: "can edit this document" needs the resource, so call
IAuthorizationService.AuthorizeAsync(user, resource, policy). - 💡 Keep roles few and coarse: when you find yourself inventing
"Over18"or"VerifiedEmail"roles, those are claims — model them as claims.
Common Errors (and the fix)
- Role explosion: you keep adding roles like
"AdminUK","AdminUKReadOnly","AdminUKReadOnly2024"until nobody knows who can do what. Fix: model the varying parts as claims (region, permission) and authorize with a policy that reads them. - Authorizing by role instead of capability:
[Authorize(Roles = "SeniorManager")]scattered across the app breaks the day the role is renamed. Fix: define[Authorize(Policy = "CanApproveRefund")]once and map roles/claims to it in one place. - Trusting client-supplied claims: reading an
"isAdmin"value the browser sent (a header, a cookie field, an unsigned token) lets anyone elevate themselves. Fix: only authorize on claims issued and signed by your server inside a validated token. - Missing default-deny: with no
FallbackPolicy, an endpoint you forget to decorate is wide open. Fix: set a fallback that requires an authenticated user, then add[AllowAnonymous]deliberately. - "InvalidOperationException: No policy found: PremiumUser": you referenced a policy name you never registered. Fix: add it in
AddAuthorization(o => o.AddPolicy("PremiumUser", ...))and check the spelling exactly.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Require a role (OR) | [Authorize(Roles="A,B")] | A or B |
| Require roles (AND) | stack two [Authorize] | A and B |
| Define a policy | o.AddPolicy("P", p => ...) | In AddAuthorization |
| Use a policy | [Authorize(Policy="P")] | By name |
| Require a claim | p.RequireClaim("age","18") | Type + value(s) |
| Read a claim | User.FindFirst("dept")?.Value | On ClaimsPrincipal |
| Check a role | User.IsInRole("Admin") | Role is a claim |
| Default-deny | options.FallbackPolicy = ... | + [AllowAnonymous] |
Frequently Asked Questions
Q: What's the real difference between authentication and authorization?
Authentication proves who you are (login, token validation) and produces a failed result of 401 Unauthorized. Authorization decides what you may do and fails with 403 Forbidden. You're authenticated first, then authorized.
Q: When should I use a role versus a claim?
Use a role for coarse, stable groups ("Admin", "Staff"). Use a claim when the decision depends on a fact about the user — age, department, subscription tier, email-verified. If you're tempted to invent a role like "Over18", that's really a claim.
Q: Aren't policy-based and claims-based the same thing?
Closely related. A policy is the named container of requirements; a claim requirement is one kind of requirement you can put in it. Claims-based authorization is usually implemented as a policy that requires certain claims.
Q: Why can't I just trust the claims the browser sends?
Because the client controls them. An attacker can set isAdmin=true in a header or an unsigned token. Only authorize on claims your server issued and signed — for example the claims inside a JWT you validated against your signing key.
Q: What's the safest default for a new app?
Default-deny. Set a FallbackPolicy that requires an authenticated user so every endpoint is protected unless you mark it [AllowAnonymous]. That way forgetting to add [Authorize] can't leak a private endpoint.
Mini-Challenge: Role AND Claim Authorizer
No blanks this time — just a brief and an outline. Write a tiny authorizer that grants access only when the user has both the "Editor" role and a signed ("email_verified", "true") claim — combining role-based and claims-based checks the way real policies do. Run it and match the expected output.
🎯 Mini-Challenge: combine a role and a claim
Grant only when both the Editor role and the email_verified claim are present.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 MINI-CHALLENGE: a tiny authorizer that requires a ROLE *and* a CLAIM.
//
// A user is represented by two lists:
// roles -> e.g. new List<string> { "Editor" }
// claims -> e.g. new List<(string, string)> { ("email_verified", "true") }
//
// 1. Write a method Authorize(roles, claims) that returns true ONLY if:
// - roles contains "Editor" (a role requirement), AND
// - claims contains (
...🎉 Lesson Complete
- ✅ Authentication proves who you are (401); authorization decides what you may do (403)
- ✅ RBAC grants by role label —
roles.Contains/[Authorize(Roles = "...")] - ✅ Claims are
(type, value)facts; authorize on the facts, not invented roles - ✅ Policies bundle requirements behind a name with
AddAuthorization(o => o.AddPolicy(...)) - ✅ The signed-in user is a ClaimsPrincipal — read it with
FindFirst,HasClaim,IsInRole - ✅ Least privilege + a default-deny
FallbackPolicykeep new endpoints safe by default - ✅ Next lesson: Caching Strategies — speed up your app without re-doing expensive work
Sign up for free to track which lessons you've completed and get learning reminders.