Security Models
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 examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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.
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.
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.
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.
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:
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.
🔎 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.
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.
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.
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.
🎉 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
- ✅ Next lesson: Caching Strategies — speed up your app without re-doing expensive work
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?
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
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: JWT Authentication
- Next: Caching Strategies