Authentication tells you who is calling. Authorization decides what they are allowed to do, and getting it wrong is one of the most common, and most damaging, API security failures. Role-based authorization (RBAC) is the workhorse approach: assign users to roles, then gate endpoints by role.
By the end of this post you will have an ASP.NET Core API that stores users and roles in PostgreSQL via EF Core, projects those roles into the user's claims, and enforces them on endpoints with both attribute-based and policy-based authorization.
Before starting, make sure you have:
- .NET 8 SDK and a running PostgreSQL instance (local Docker container is fine)
- The
Npgsql.EntityFrameworkCore.PostgreSQLpackage installed - A working JWT authentication setup, since roles are carried as claims inside the token (see the previous post in this series)
RBAC is fundamentally a many-to-many relationship: a user can hold several roles, and a role belongs to many users. We model that explicitly with a join entity so the schema stays clean and queryable in PostgreSQL.
The entities
Keeping roles as their own table (rather than a comma-separated string column) means you can query, audit, and extend them later without a painful migration.
// Entities
public sealed class AppUser
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}
public sealed class AppRole
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty; // "Admin", "Manager", "User"
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}
public sealed class UserRole
{
public Guid UserId { get; set; }
public AppUser User { get; set; } = null!;
public int RoleId { get; set; }
public AppRole Role { get; set; } = null!;
}
Configure the join in the DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserRole>()
.HasKey(ur => new { ur.UserId, ur.RoleId });
modelBuilder.Entity<UserRole>()
.HasOne(ur => ur.User)
.WithMany(u => u.UserRoles)
.HasForeignKey(ur => ur.UserId);
modelBuilder.Entity<UserRole>()
.HasOne(ur => ur.Role)
.WithMany(r => r.UserRoles)
.HasForeignKey(ur => ur.RoleId);
modelBuilder.Entity<AppRole>()
.HasData(
new AppRole { Id = 1, Name = "Admin" },
new AppRole { Id = 2, Name = "Manager" },
new AppRole { Id = 3, Name = "User" });
}
The composite primary key on UserRole prevents duplicate assignments at the database level, and seeding the roles with HasData guarantees they exist after the first migration. Run dotnet ef migrations add InitRoles followed by dotnet ef database update to apply this to PostgreSQL.
For attribute-based role checks to work, the role names must be present in the authenticated user's claims. When a user logs in, we load their roles from PostgreSQL and add each one as a ClaimTypes.Role claim on the token.
// During login, after verifying the password
var user = await _db.Users
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(u => u.Email == request.Email);
var roleNames = user!.UserRoles.Select(ur => ur.Role.Name).ToList();
var accessToken = _tokenService.CreateAccessToken(user.Id, user.Email, roleNames);
The eager Include and ThenInclude matter: without them the role collection is empty and every role check silently fails. ASP.NET Core maps ClaimTypes.Role claims into the role system automatically, so User.IsInRole("Admin") just works once the token carries them.
There are two ways to gate access, and mature APIs use both. Attribute-based checks are simple and declarative; policy-based checks centralize complex rules so they are not duplicated across controllers.
Attribute-based, for simple gates
[Authorize(Roles = "Admin")]
[HttpDelete("users/{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id)
{
// Only Admins reach this line.
await _userService.DeleteAsync(id);
return NoContent();
}
[Authorize(Roles = "Admin,Manager")]
[HttpGet("reports")]
public IActionResult GetReports() => Ok(_reportService.GetAll());
Policy-based, for reusable rules
When the same combination of roles guards many endpoints, define it once as a named policy. Changing the rule then happens in a single place rather than across dozens of attributes.
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageContent", policy =>
policy.RequireRole("Admin", "Manager"));
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
});
[Authorize(Policy = "CanManageContent")]
[HttpPost("articles")]
public async Task<IActionResult> CreateArticle([FromBody] ArticleDto dto)
{
var id = await _articleService.CreateAsync(dto);
return CreatedAtAction(nameof(CreateArticle), new { id }, null);
}
A request from a user without the required role returns 403 Forbidden (they are authenticated but not permitted), while a request with no valid token returns 401 Unauthorized. Distinguishing those two responses correctly is a sign your pipeline is configured properly.
The fastest way to gain confidence is to test the full matrix: each role against each protected endpoint, plus the anonymous case. A small integration test makes regressions impossible to miss.
[Theory]
[InlineData("Admin", "/api/users/{id}", "DELETE", 204)]
[InlineData("Manager", "/api/users/{id}", "DELETE", 403)]
[InlineData("User", "/api/reports", "GET", 403)]
[InlineData(null, "/api/reports", "GET", 401)]
public async Task Endpoints_enforce_roles(
string? role, string path, string method, int expectedStatus)
{
var client = _factory.CreateAuthenticatedClient(role);
var response = await client.SendAsync(new HttpRequestMessage(
new HttpMethod(method), path));
Assert.Equal(expectedStatus, (int)response.StatusCode);
}
When this theory passes, you have proof that Admins can delete, Managers cannot, Users are blocked from reports, and anonymous callers get a clean 401. That table is also excellent living documentation of your access rules.
You now have a PostgreSQL-backed RBAC model in EF Core, roles flowing into JWT claims at login, and endpoints protected by both attribute and policy-based authorization, all verified by a test matrix. That is a production-grade foundation for controlling access in any .NET 8 API.
Next in the series we shift from security to throughput: running work outside the request thread with background jobs using IHostedService and Hangfire.
No comments: