The Problem With Ad-Hoc Access Control
Most access control bugs are not security vulnerabilities in the traditional sense. They are design decisions that were never made explicitly. A system that started with two user types gains a third, then a fourth. Access rules get added wherever they are immediately needed — a check here, a role string there, a frontend button hidden for certain users. Over time, no single developer can tell you with confidence who is allowed to do what.
The solution is not more code. It is a deliberate structure: roles defined in one place, carried in the authentication token, and enforced at the API layer with zero ambiguity.
Step 1 — Define Roles as an Enum
The first decision is where roles live. Magic strings like "Admin" or "manager" scattered across controllers are fragile — a typo fails silently at runtime. An enum fails at compile time, which is exactly what you want.
public enum UserRole
{
Client = 1,
Staff = 2,
Manager = 3,
Admin = 4
}
Assigning integer values explicitly is intentional. It means the stored value in the database remains stable even if the enum is reordered later. Store the integer, display the name.
Add the role to your user entity:
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public UserRole Role { get; set; } = UserRole.Client;
}
Step 2 — Store the Role as a Claim in the JWT
When issuing a JWT after successful login, include the user's role as a claim. ASP.NET Core's authorization middleware reads ClaimTypes.Role automatically when you use the [Authorize(Roles = "...")] attribute.
public string GenerateAccessToken(User user)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role.ToString())
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"]!)
);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
The role is now in every request as part of the signed token. The server does not need to query the database on every request to check the user's role — it reads the claim directly.
Step 3 — Enforce at the Controller Level
The most important rule: authorization must be enforced at the API layer, not the frontend. Hiding a button from non-admin users is good UX. It is not security. Any developer with a REST client can call your endpoints directly, bypassing everything the UI does.
[ApiController]
[Route("api/[controller]")]
[Authorize] // all actions require a valid token by default
public class UsersController : ControllerBase
{
// Any authenticated user can view their own profile
[HttpGet("me")]
public async Task<IActionResult> GetProfile() { ... }
// Staff and above can view the client list
[Authorize(Roles = "Staff,Manager,Admin")]
[HttpGet]
public async Task<IActionResult> GetAllUsers() { ... }
// Manager and above can update user details
[Authorize(Roles = "Manager,Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateUser(Guid id, UpdateUserRequest request) { ... }
// Only admins can delete users
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id) { ... }
}
Applying [Authorize] at the controller level means every action is protected by default. You opt out of auth for specific actions with [AllowAnonymous] — the safer default compared to opting in per action.
Step 4 — Use Policies for Complex Rules
Role strings on attributes work well for simple cases. When access rules involve multiple conditions — role plus a verified status, role plus a specific claim — policies are cleaner and easier to maintain.
Define policies in Program.cs once:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanApproveOrders", policy =>
policy.RequireRole(
UserRole.Admin.ToString(),
UserRole.Manager.ToString()
));
options.AddPolicy("VerifiedClientOnly", policy =>
policy.RequireRole(UserRole.Client.ToString())
.RequireClaim("account_verified", "true"));
options.AddPolicy("SeniorStaffAndAbove", policy =>
policy.RequireRole(
UserRole.Manager.ToString(),
UserRole.Admin.ToString()
).RequireClaim("employment_type", "permanent"));
});
Apply them on the controller:
[Authorize(Policy = "CanApproveOrders")]
[HttpPost("orders/{id}/approve")]
public async Task<IActionResult> ApproveOrder(Guid id) { ... }
[Authorize(Policy = "VerifiedClientOnly")]
[HttpGet("invoices")]
public async Task<IActionResult> GetInvoices() { ... }
Policies express intent clearly. The name "CanApproveOrders" communicates what the rule is for, not just which roles it applies to.
Step 5 — Row-Level Data Isolation
Controller-level authorization controls which endpoints a user can reach. Row-level isolation controls which data they can see within those endpoints. Both are necessary.
A Client should only see their own orders. A Manager should see orders for their region. An Admin sees everything. This logic belongs in the query, not in a post-fetch filter:
[Authorize]
[HttpGet("orders")]
public async Task<IActionResult> GetOrders()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var userRole = Enum.Parse<UserRole>(User.FindFirstValue(ClaimTypes.Role)!);
var query = _db.Orders.AsQueryable();
query = userRole switch
{
UserRole.Admin => query, // sees all
UserRole.Manager => query.Where(o => o.AssignedRegion == GetUserRegion()),
UserRole.Staff => query.Where(o => o.AssignedStaffId == userId),
UserRole.Client => query.Where(o => o.ClientId == userId),
_ => query.Where(_ => false) // unknown role — return nothing
};
var orders = await query.ToListAsync();
return Ok(orders);
}
The switch expression makes the data access rules explicit and readable. Adding a new role means adding one case — it does not require hunting for scattered WHERE clauses.
Step 6 — Helper Method for Role Checks in Services
Sometimes you need to check roles in service logic rather than at the controller layer. Pass the current user's role into the service rather than re-querying the database:
public static class RoleExtensions
{
public static UserRole GetUserRole(this ClaimsPrincipal user)
=> Enum.Parse<UserRole>(user.FindFirstValue(ClaimTypes.Role)!);
public static bool IsAtLeast(this UserRole role, UserRole minimum)
=> (int)role >= (int)minimum;
}
// In a controller or service
var role = User.GetUserRole();
if (!role.IsAtLeast(UserRole.Manager))
return Forbid();
The IsAtLeast helper works because the enum values are explicitly ordered numerically. Admin (4) is always at least Manager (3), Manager is always at least Staff (2). This only holds if you keep the integer values in order when you add new roles.
Step 7 — Role Management Endpoint
Admins need a way to change a user's role. Restrict this endpoint tightly — only Admins can promote or demote users, and you should never allow a user to elevate their own role:
[Authorize(Roles = "Admin")]
[HttpPatch("users/{id}/role")]
public async Task<IActionResult> UpdateRole(Guid id, [FromBody] UpdateRoleRequest request)
{
var callerId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
if (callerId == id)
return BadRequest(new { error = "You cannot change your own role." });
var user = await _db.Users.FindAsync(id);
if (user is null) return NotFound();
user.Role = request.Role;
await _db.SaveChangesAsync();
return NoContent();
}
public record UpdateRoleRequest(UserRole Role);
Putting It All Together — The Structure
The complete pattern has four layers, each with a clear responsibility:
- Enum — single source of truth for all roles in the system
- JWT claim — role travels with every request, no database lookup needed
- Controller attributes — endpoint-level access control, enforced before any business logic runs
- Query filters — row-level isolation, enforced at the data layer
This structure scales cleanly. Adding a new role means adding one enum value and updating the relevant policies and query filters. Nothing else changes. Auditing who can do what means reading one enum definition and one authorization setup file — not grepping an entire codebase for role strings.
Implementation Checklist
- Roles defined as an enum with explicit integer values
- Role stored as an integer in the database, resolved to the enum in application code
- Role included as a
ClaimTypes.Roleclaim in the JWT [Authorize]applied at controller level — opt out with[AllowAnonymous], not opt in per action- Complex rules expressed as named policies in
Program.cs - Data queries filtered by role — never return all rows and filter in memory
- Role update endpoint restricted to Admin and blocked for self-elevation
For the JWT implementation this authorization layer sits on top of, see the post on JWT authentication in C#. For 2FA on top of this auth stack, see implementing 2FA with QR code in ASP.NET Core. If you are building a multi-user system and want the access control designed correctly from the start, see the .NET backend development services page or get in touch.