Role-Based, Policy-Based & Claims-Based Security
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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.
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)
{
Name = name;
IsAuthenticated = isAuthenticated;
Roles = roles;
}
}
class Program
{
static void Main()
{
// Step 1 — AUTHENTICATION already happened: this user proved who they are.
var user = new User("Alice", true, new List<string> { "Editor" });
// Step 2 — AUTHORIZATION: a *different* question entirely.
if (!user.IsAuthenticated)
Console.WriteLine("401 Unauthenticated — please log in.");
else if (user.Roles.Contains("Admin"))
Console.WriteLine($"{user.Name}: access granted to admin area.");
else
Console.WriteLine($"403 Forbidden — {user.Name} is authenticated but not allowed.");
// ✅ Expected output:
// 403 Forbidden — Alice is authenticated but not allowed.
}
}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.
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".
bool canAccessAdmin = user.Roles.___("Admin"); // 👉 use the List method Contains
// 2) Print the decision based on that boolean.
if (___) // 👉 the variable you just set
Console.WriteLine($"{user.Name}: 200 OK — admin dashboard.");
else
Console.WriteLine($"{user.Name}: 403 Forbidden.");
// ✅ Expected output:
// Bob: 200 OK — admin dashboard.
}
}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.
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()
=> Ok(new { Message = "Welcome, Admin!" });
// ✅ Admin -> 200 OK { Message = "Welcome, Admin!" }
// ✅ Editor (no Admin role) -> framework returns 403 Forbidden automatically
// Comma-separated roles = OR. Admin OR Manager may view reports.
[HttpGet("reports")]
[Authorize(Roles = "Admin,Manager")]
public IActionResult GetReports()
=> Ok(new { Access = "Reports" });
// ✅ Manager -> 200 OK ; Editor -> 403 Forbidden
// Stack [Authorize] attributes for AND: the user needs BOTH roles.
[HttpDelete("users/{id}")]
[Authorize(Roles = "Admin")]
[Authorize(Roles = "SuperAdmin")]
public IActionResult DeleteUser(int id)
=> Ok(new { Deleted = id });
// ✅ Admin+SuperAdmin -> 200 OK ; Admin only -> 403 Forbidden
}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.
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"),
("age", "21"),
("country", "UK")
};
// A POLICY is a predicate over those claims. Here: must be 18 or older.
bool IsAdult(List<(string Type, string Value)> c)
{
var ageClaim = c.FirstOrDefault(x => x.Type == "age");
return int.TryParse(ageClaim.Value, out int age) && age >= 18;
}
if (IsAdult(claims))
Console.WriteLine("Access granted: user meets the 18+ policy.");
else
Console.WriteLine("403 Forbidden: user is under 18.");
// ✅ Expected output:
// Access granted: user meets the 18+ policy.
}
}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:
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) The policy passes only if age is 18 or more.
bool allowed = int.TryParse(ageClaim.Value, out int age) && age ___ 18; // 👉 the >= operator
Console.WriteLine(allowed ? "Granted (18+)" : "Denied (under 18)");
// ✅ Expected output:
// Denied (under 18)
}
}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.
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("email_verified", "true"));
// Combine requirements with AND: authenticated, a "User" role, AND a premium tier.
options.AddPolicy("PremiumUser", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("User");
policy.RequireClaim("subscription", "premium", "enterprise");
});
// DEFAULT-DENY: anything without an explicit [AllowAnonymous] needs a login.
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
// ClaimsPrincipal is how ASP.NET represents the signed-in user. It holds the
// claims, and you read facts off it the same way our plain example did.
static void Inspect(ClaimsPrincipal user)
{
bool isAuthed = user.Identity?.IsAuthenticated ?? false;
string? name = user.FindFirst(ClaimTypes.Name)?.Value; // a single claim's value
bool isPremium = user.HasClaim("subscription", "premium"); // a fact check
bool isAdmin = user.IsInRole("Admin"); // roles are just claims too
Console.WriteLine($"authed={isAuthed}, name={name}, premium={isPremium}, admin={isAdmin}");
// ✅ For Alice (premium, no Admin role):
// authed=True, name=Alice, premium=True, admin=False
}🔎 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 FallbackPolicy plus [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.
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 ("email_verified", "true") (a claim requirement).
// Tip: roles.Contains("Editor")
// claims.Any(c => c.Type == "email_verified" && c.Value == "true")
// 2. Test it with a user who has BOTH (expect Granted)
// and one missing the claim (expect Denied).
//
// ✅ Expected output:
// Granted
// Denied
class Program
{
static void Main()
{
// your code here
}
}🎉 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 FallbackPolicy keep new endpoints safe by default
Practice quiz
What does authentication answer?
- What may you do?
- Where are you?
- Who are you?
- When did you log in?
Answer: Who are you?. Authentication proves WHO you are; authorization decides WHAT you may do.
An authenticated user who is denied access to an admin area gets which status code?
- 403 Forbidden
- 401 Unauthorized
- 404 Not Found
- 200 OK
Answer: 403 Forbidden. A logged-in but disallowed user is 403 Forbidden; 401 means not authenticated.
What does role-based access control (RBAC) decide access on?
- A fact like age
- The request URL
- The time of day
- A role label on the user
Answer: A role label on the user. RBAC grants access when a required role label is present, e.g. roles.Contains("Admin").
In [Authorize(Roles = "Admin,Manager")], what do the comma-separated roles mean?
- The user needs BOTH roles (AND)
- The user needs ANY one of them (OR)
- Neither role is required
- Only the first is checked
Answer: The user needs ANY one of them (OR). Comma-separated roles mean OR - any one matching grants access. Stacking [Authorize] attributes means AND.
How do you require that a user has BOTH the Admin AND SuperAdmin roles?
- Stack two [Authorize] attributes
- [Authorize(Roles = "Admin,SuperAdmin")]
- [Authorize(Roles = "Admin&SuperAdmin")]
- It can't be done
Answer: Stack two [Authorize] attributes. Stacking [Authorize(Roles="Admin")] and [Authorize(Roles="SuperAdmin")] requires both (AND).
What is a claim?
- A role name
- An HTTP header
- A single fact about a user as a (type, value) pair
- A database table
Answer: A single fact about a user as a (type, value) pair. A claim is a (type, value) fact like ("age", "21") or ("email_verified", "true").
How does ASP.NET Core represent the signed-in user inside the app?
- A string username
- A ClaimsPrincipal
- A DbContext
- An HttpRequest
Answer: A ClaimsPrincipal. The signed-in user is a ClaimsPrincipal; read facts with FindFirst, HasClaim, and IsInRole.
What does a default-deny FallbackPolicy that requires an authenticated user achieve?
- It makes every endpoint public
- It disables authorization
- It speeds up requests
- Every endpoint is protected unless explicitly marked [AllowAnonymous]
Answer: Every endpoint is protected unless explicitly marked [AllowAnonymous]. A FallbackPolicy locks new endpoints by default; you opt out per-endpoint with [AllowAnonymous].
Why must you never authorize on a claim the client itself can set?
- It's slower
- The client controls it, so an attacker could set isAdmin=true and elevate themselves
- Claims can't be read on the server
- It breaks JSON serialization
Answer: The client controls it, so an attacker could set isAdmin=true and elevate themselves. Only authorize on claims your server issued and signed (e.g. inside a validated JWT), never client-supplied ones.
When you find yourself inventing a role like 'Over18', what does that usually indicate?
- You need more roles
- Authorization isn't possible
- It's really a claim - model it as a claim, not a role
- You should use 401
Answer: It's really a claim - model it as a claim, not a role. Facts about a user (age, verified email) are claims; reach for the simplest model that expresses the rule.
Continue this course
- Previous: Authentication & Authorization with JWT (Advanced)
- Next: Caching Strategies (MemoryCache, Distributed Cache, Redis) — Add in-memory and distributed caching with IMemoryCache and Redis
- Quick reference: C# cheat sheet