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

💡 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

ModelDecides onTypical codeBest for
RBAC (role)A role label on the user[Authorize(Roles="Admin")]Coarse, stable groups
PolicyA named bundle of rules[Authorize(Policy="Premium")]Combining several conditions
ClaimsA 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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Require a role (OR)[Authorize(Roles="A,B")]A or B
Require roles (AND)stack two [Authorize]A and B
Define a policyo.AddPolicy("P", p => ...)In AddAuthorization
Use a policy[Authorize(Policy="P")]By name
Require a claimp.RequireClaim("age","18")Type + value(s)
Read a claimUser.FindFirst("dept")?.ValueOn ClaimsPrincipal
Check a roleUser.IsInRole("Admin")Role is a claim
Default-denyoptions.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

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