.NET & C# Development · Lesson 35 of 92

Lock Down Endpoints With Roles & Custom Claims

Adding Roles to JWT Claims

Roles are just claims with the type ClaimTypes.Role. Add them when you generate the access token:

C#
public string GenerateAccessToken(User user, IList<string> roles)
{
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub,   user.Id.ToString()),
        new(JwtRegisteredClaimNames.Email, user.Email),
        new(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
    };

    // Add each role as a separate claim
    foreach (var role in roles)
        claims.Add(new Claim(ClaimTypes.Role, role));

    // Or add a custom department claim
    if (!string.IsNullOrEmpty(user.Department))
        claims.Add(new Claim("department", user.Department));

    // Subscription tier
    claims.Add(new Claim("subscription", user.SubscriptionTier));

    var key   = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
    var token = new JwtSecurityToken(
        issuer:   _settings.Issuer,
        audience: _settings.Audience,
        claims:   claims,
        expires:  DateTime.UtcNow.AddMinutes(15),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return new JwtSecurityTokenHandler().WriteToken(token);
}

The resulting JWT payload:

JSON
{
  "sub": "42",
  "email": "alice@example.com",
  "role": ["Admin", "Manager"],
  "department": "Engineering",
  "subscription": "Pro",
  "exp": 1714000000
}

[Authorize(Roles = ...)] Attribute

C#
[ApiController]
[Route("api/users")]
[Authorize] // all actions require authentication
public class UsersController : ControllerBase
{
    // Only Admin can delete users
    [HttpDelete("{id}")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> DeleteUser(int id) { /* ... */ }

    // Admin OR Manager can view user details
    [HttpGet("{id}")]
    [Authorize(Roles = "Admin,Manager")]
    public async Task<IActionResult> GetUser(int id) { /* ... */ }

    // Any authenticated user can view their own profile
    [HttpGet("me")]
    public IActionResult GetProfile() { /* ... */ }

    // Public endpoint — no auth required
    [HttpGet("count")]
    [AllowAnonymous]
    public async Task<IActionResult> GetUserCount() { /* ... */ }
}

[Authorize(Roles = "Admin,Manager")] uses OR logic — the user needs either role, not both. For AND logic (user must have all roles), use policies.

Policy-Based Authorization

Policies give you more expressive rules than role attributes:

C#
// Program.cs
builder.Services.AddAuthorization(options =>
{
    // Must have Admin AND Manager roles
    options.AddPolicy("AdminAndManager", policy =>
        policy.RequireRole("Admin", "Manager").RequireAssertion(ctx =>
            ctx.User.IsInRole("Admin") && ctx.User.IsInRole("Manager")));

    // Must have Pro or Enterprise subscription
    options.AddPolicy("PaidTier", policy =>
        policy.RequireClaim("subscription", "Pro", "Enterprise"));

    // Department-specific policy
    options.AddPolicy("Engineering", policy =>
        policy.RequireClaim("department", "Engineering"));

    // Custom complex policy
    options.AddPolicy("SeniorEngineer", policy =>
        policy.RequireAssertion(ctx =>
            ctx.User.IsInRole("Engineer") &&
            ctx.User.HasClaim("department", "Engineering") &&
            ctx.User.HasClaim(c => c.Type == "level" && int.Parse(c.Value) >= 5)));
});

Use policies in controllers:

C#
[Authorize(Policy = "PaidTier")]
[HttpGet("advanced-features")]
public IActionResult GetAdvancedFeatures() { /* ... */ }

IClaimsTransformation — Adding Dynamic Claims

Sometimes a claim's value isn't known at login time — it changes based on current DB state (e.g., subscription status, feature flags, permissions loaded from a DB table).

IClaimsTransformation runs on every authenticated request and lets you augment the claims principal:

C#
public class DynamicClaimsTransformation : IClaimsTransformation
{
    private readonly IPermissionService _permissions;

    public DynamicClaimsTransformation(IPermissionService permissions)
        => _permissions = permissions;

    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        // Avoid transforming the same principal twice (it may be called multiple times)
        if (principal.HasClaim(c => c.Type == "permissions_loaded"))
            return principal;

        var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (userId is null) return principal;

        // Load current permissions from DB
        var permissions = await _permissions.GetUserPermissionsAsync(int.Parse(userId));

        var identity = new ClaimsIdentity();
        identity.AddClaim(new Claim("permissions_loaded", "true"));

        foreach (var permission in permissions)
            identity.AddClaim(new Claim("permission", permission));

        // Add dynamic subscription status
        var subscriptionStatus = await _permissions.GetSubscriptionStatusAsync(int.Parse(userId));
        identity.AddClaim(new Claim("subscription_active", subscriptionStatus.ToString().ToLower()));

        principal.AddIdentity(identity);
        return principal;
    }
}

Register it:

C#
builder.Services.AddScoped<IClaimsTransformation, DynamicClaimsTransformation>();

Note: IClaimsTransformation fires on every request. Keep it fast — cache the permissions lookup with IMemoryCache or a short-lived distributed cache:

C#
var cacheKey = $"permissions:{userId}";
var permissions = await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
    return await _permissionsDb.GetUserPermissionsAsync(int.Parse(userId));
});

Custom Authorization Requirements

For complex rules that don't fit into policies:

C#
// Requirement: user must own the resource
public class ResourceOwnerRequirement : IAuthorizationRequirement
{
    public ResourceOwnerRequirement() { }
}

public class ResourceOwnerHandler : AuthorizationHandler<ResourceOwnerRequirement, int>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ResourceOwnerRequirement requirement,
        int resourceOwnerId) // the resource's owner ID, passed when checking
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

        if (userId is not null && int.Parse(userId) == resourceOwnerId)
            context.Succeed(requirement);
        else if (context.User.IsInRole("Admin")) // admins can access anything
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

Register and use:

C#
builder.Services.AddSingleton<IAuthorizationHandler, ResourceOwnerHandler>();

options.AddPolicy("ResourceOwner", policy =>
    policy.Requirements.Add(new ResourceOwnerRequirement()));
C#
// In a controller:
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePost(int id)
{
    var post = await _db.Posts.FindAsync(id);
    if (post is null) return NotFound();

    var authResult = await _authorizationService.AuthorizeAsync(
        User, post.AuthorId, "ResourceOwner");

    if (!authResult.Succeeded) return Forbid();

    _db.Posts.Remove(post);
    await _db.SaveChangesAsync();
    return NoContent();
}

Authorization in the Service Layer

Don't rely solely on controller attributes. Validate authorization in the service layer too — controllers can be bypassed (background jobs, other services, future gRPC endpoints):

C#
public class DocumentService
{
    private readonly IAuthorizationService _auth;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public DocumentService(IAuthorizationService auth, IHttpContextAccessor accessor)
    {
        _auth = auth;
        _httpContextAccessor = accessor;
    }

    public async Task DeleteDocumentAsync(int documentId)
    {
        var document = await _db.Documents.FindAsync(documentId)
            ?? throw new KeyNotFoundException();

        // Authorization check inside the service
        var user = _httpContextAccessor.HttpContext?.User
            ?? throw new UnauthorizedAccessException();

        var result = await _auth.AuthorizeAsync(user, document.OwnerId, "ResourceOwner");
        if (!result.Succeeded)
            throw new ForbiddenException("You do not have permission to delete this document.");

        _db.Documents.Remove(document);
        await _db.SaveChangesAsync();
    }
}

Reading Claims in Controllers

C#
// Get current user's ID from JWT sub claim
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);

// Check a specific claim value
var subscriptionTier = User.FindFirstValue("subscription");
var isPro = subscriptionTier is "Pro" or "Enterprise";

// Get all roles
var roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList();

// Extension method for cleaner code
public static class ClaimsPrincipalExtensions
{
    public static int GetUserId(this ClaimsPrincipal user)
        => int.Parse(user.FindFirstValue(ClaimTypes.NameIdentifier)
           ?? throw new InvalidOperationException("User ID claim missing."));

    public static bool HasPermission(this ClaimsPrincipal user, string permission)
        => user.HasClaim("permission", permission);
}

Usage:

C#
[HttpGet("my-orders")]
[Authorize]
public async Task<IActionResult> GetMyOrders()
{
    var userId = User.GetUserId();
    var orders = await _db.Orders
        .Where(o => o.UserId == userId)
        .AsNoTracking()
        .ToListAsync();
    return Ok(orders);
}