.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:
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:
{
"sub": "42",
"email": "alice@example.com",
"role": ["Admin", "Manager"],
"department": "Engineering",
"subscription": "Pro",
"exp": 1714000000
}[Authorize(Roles = ...)] Attribute
[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:
// 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:
[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:
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:
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:
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:
// 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:
builder.Services.AddSingleton<IAuthorizationHandler, ResourceOwnerHandler>();
options.AddPolicy("ResourceOwner", policy =>
policy.Requirements.Add(new ResourceOwnerRequirement()));// 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):
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
// 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:
[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);
}