Previous Security in NET Core Dry Run in Program Next

RBAC vs claims-based in ASP.NET Core

RBAC vs claims-based in ASP.NET Core: a mental model

  • Core idea: Roles answer “who you are in the org” (Admin, Manager). Claims answer “what you have/are allowed or facts about you” (permission=export:reports, country=IN, tenant=42).
  • How ASP.NET Core uses them: Both flow through the same authorization system. You can authorize with [Authorize(Roles="...")] for roles or [Authorize(Policy="...")] for claims or composite policies.
  • Design stance: Prefer policy-based authorization. Define intent (e.g., "CanExportReports") as a policy, then satisfy it via roles, claims, or custom logic. Roles and claims become inputs, not the API surface your controllers depend on.

Role-based authorization (RBAC)

What it is

  • Definition: Assign users to roles; protect endpoints by listing allowed roles.
  • Expression: [Authorize(Roles="Admin,Auditor")] or policies using RequireRole("Admin").

Pros

  • Simplicity: Easy to understand, audit, and manage for coarse-grained access.
  • Stability: Roles change less frequently than fine-grained permissions.
  • Tooling: First-class support in ASP.NET Core Identity, Azure AD, and JWTs.

Cons

  • Coarse-grained: Quickly balloons into many roles to capture nuanced permissions (“role explosion”).
  • Poor portability: Cross-tenant or B2B scenarios often need data-driven permissions beyond roles.
  • Coupling: Controllers coupled to org structure; refactors in org chart ripple into code.

Best fit

  • Internal apps: Clear job functions and stable hierarchy (Admin, HR, Finance).
  • Regulated access: Where audits expect role assignment evidence.

Claims-based authorization

What it is

  • Definition: Evaluate one or more claims (key–value facts) on the identity. Examples: permission=export:reports, dept=finance, subscription=premium.
  • Expression: Policies like RequireClaim("permission", "export:reports") or custom requirements.

Pros

  • Fine-grained: Express exact capabilities without proliferating roles.
  • Federation-friendly: Works well with external IdPs that issue claims (OpenID Connect/JWT).
  • Dynamic: Claims can be minted at sign-in based on current state (tenant, licensing, flags).

Cons

  • Governance burden: Requires a permission model, naming conventions, and lifecycle management.
  • Token bloat: Too many claims increase token size; impacts headers and gateways.
  • Complexity: More moving parts (issuance, transformation, caching) than simple roles.

Best fit

  • Multi-tenant SaaS: Per-tenant entitlements and feature flags.
  • B2B/B2C: External identity providers issuing claims; capability-based access.
  • Domain-driven apps: Permissions aligned to business capabilities, not org titles.

When to use which

  • If access maps to job titles: Start with roles. Keep the list short and stable.
  • If access maps to capabilities: Use claims for permissions (permission=create:invoice).
  • If you need both: Define policies and satisfy them by either role or claim (composite policy).
  • If you integrate with IdPs: Prefer claims the IdP can issue; map to policies in your API.
  • If auditability is paramount: Roles plus policies give clear narratives for auditors.
  • Pragmatic rule: Expose policies to code, manage roles/claims behind the policy line.

Code examples in ASP.NET Core

Program.cs (JWT + policies for roles and claims)

// using Microsoft.AspNetCore.Authentication.JwtBearer;
// using Microsoft.AspNetCore.Authorization;
// using Microsoft.IdentityModel.Tokens;
// using System.Text;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key")),
            NameClaimType = "name",
            RoleClaimType = "role"
        };
    });

builder.Services.AddAuthorization(options =>
{
    // Role-based policy
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));

    // Claims-based policy
    options.AddPolicy("CanExportReports", policy =>
        policy.RequireClaim("permission", "export:reports"));

    // Composite policy
    options.AddPolicy("FinanceOrExporter", policy =>
        policy.RequireAssertion(ctx =>
            ctx.User.IsInRole("Finance") ||
            ctx.User.HasClaim("permission", "export:reports")));
});

builder.Services.AddControllers();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

Controller usage

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
    // Role-based
    [HttpDelete("{id}")]
    [Authorize(Roles = "Admin")]
    public IActionResult DeleteReport(int id) => Ok($"Deleted {id}");

    // Claims-based
    [HttpGet("export")]
    [Authorize(Policy = "CanExportReports")]
    public IActionResult Export() => Ok("Exported CSV");

    // Composite policy
    [HttpPost("finance-export")]
    [Authorize(Policy = "FinanceOrExporter")]
    public IActionResult FinanceExport() => Ok("Finance export done");
}

Issuing claims/roles with Identity (on sign-in)

// using Microsoft.AspNetCore.Identity;
// using System.Security.Claims;

public static async Task SeedAsync(UserManager<IdentityUser> users, RoleManager<IdentityRole> roles)
{
    if (!await roles.RoleExistsAsync("Admin"))
        await roles.CreateAsync(new IdentityRole("Admin"));

    var user = await users.FindByEmailAsync("user@example.com") 
               ?? new IdentityUser { UserName = "user@example.com", Email = "user@example.com", EmailConfirmed = true };

    if (user.Id == null) await users.CreateAsync(user, "Pass@1234");

    await users.AddToRoleAsync(user, "Admin"); // Role
    await users.AddClaimAsync(user, new Claim("permission", "export:reports")); // Claim
    await users.AddClaimAsync(user, new Claim("tenant", "42")); // Claim
}

Building a JWT with role and permission claims

// using System.IdentityModel.Tokens.Jwt;
// using System.Security.Claims;
// using Microsoft.IdentityModel.Tokens;

var claims = new List<Claim>
{
    new("name", "shiv"),
    new("role", "Admin"),                 // aligns with RoleClaimType = "role"
    new("permission", "export:reports"),  // app-specific permission
    new("tenant", "42")
};

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(
    claims: claims,
    expires: DateTime.UtcNow.AddHours(1),
    signingCredentials: creds);

var jwt = new JwtSecurityTokenHandler().WriteToken(token);

Advanced guidance and common pitfalls

  • Prefer policies: Define policies that express business intent (e.g., "CanRefund"). Fulfil via RequireRole, RequireClaim, or custom IAuthorizationHandler.
  • Custom requirements: Use resource-based handlers when authorization depends on data (e.g., user owns order).
  • JWT mapping: Align RoleClaimType/NameClaimType with the claim names in your token (e.g., "role", "name"). Don’t assume defaults.
  • Minimize token size: Avoid issuing every permission as a claim. Consider coarse “scopes” + server-side checks, or use reference tokens.
  • Centralize decisions: Encapsulate complex logic in handlers/services so controllers remain declarative.
  • Auditing: Log the evaluated policy, user id/tenant, and decision for traceability.
  • Migration path: Start with roles → introduce policies → gradually replace [Authorize(Roles=...)] with [Authorize(Policy=...)] and map roles to claims behind the scenes.
  • Multi-tenant: Include tenant in claims and policies to prevent cross-tenant access; validate on every request.
  • UI hints: Use claims/roles to drive feature flags in UI, but never rely on UI to enforce security—always enforce server-side.

Sample custom requirement and handler

public class SameTenantRequirement : IAuthorizationRequirement {}

public class SameTenantHandler : AuthorizationHandler<SameTenantRequirement, string>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        SameTenantRequirement requirement,
        string resourceTenantId)
    {
        var userTenant = context.User.FindFirst("tenant")?.Value;
        if (!string.IsNullOrEmpty(userTenant) && userTenant == resourceTenantId)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

// Registration
builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("SameTenantOnly", p => p.AddRequirements(new SameTenantRequirement()));
});
builder.Services.AddSingleton<IAuthorizationHandler, SameTenantHandler>();

// Usage (e.g., in controller)
[Authorize(Policy = "SameTenantOnly")]
public async Task<IActionResult> Get(string tenantId, [FromServices] IAuthorizationService auth)
{
    var ok = await auth.AuthorizeAsync(User, tenantId, "SameTenantOnly");
    return ok.Succeeded ? Ok() : Forbid();
}
Back to Index
Previous Security in NET Core Dry Run in Program Next
*