Role Based Authorization in ASP .NET Core (using Identity and JWT)

Introduction
Authentication tells you who is calling. Authorization decides what they are allowed to do. Role-based authorization is the most common form: an Admin can hit the endpoint that adjusts salaries, a RegularEmployee cannot, and the API enforces that without a single if (user.Role == ...) scattered through your handlers.
This video wires the full path on .NET 9: ASP.NET Core Identity for user and role storage, roles seeded into the database, roles written as claims into a JWT on login, RequireRole policies on the endpoints, and refresh tokens stored in HttpOnly cookies so a browser client can stay signed in. The demo has a public-ish /api/movies endpoint that only needs a valid token and a /api/salary endpoint locked to Admin and HrManager.
🎬 Watch the full video here:
Identity with a Guid key and a custom user
The DbContext derives from IdentityDbContext<User, IdentityRole<Guid>, Guid>, which gives you the full Identity schema - users, roles, the user-role join table - with Guid primary keys. The User entity extends IdentityUser<Guid> and adds the fields this app needs:
public class User : IdentityUser<Guid>
{
public required string FirstName { get; set; }
public required string LastName { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiresAtUtc { get; set; }
}
Registration configures password rules and points Identity at EF Core:
builder.Services.AddIdentity<User, IdentityRole<Guid>>(opt => { /* password policy */ })
.AddEntityFrameworkStores<ApplicationDbContext>();
Seeding roles as data
Roles are reference data, so they go in with HasData in OnModelCreating and ship as part of a migration - fixed Guids, names, and normalized names for Admin, RegularEmployee, and HrManager. That means the roles exist the moment the database is created; you are not seeding them from startup code that might race or run twice. On registration, the service maps the requested role to its Identity role name and calls _userManager.AddToRoleAsync(user, roleName).
Roles travel in the JWT as claims
This is the key idea. When a user logs in, the token processor pulls their roles from Identity and writes each one as a ClaimTypes.Role claim inside the JWT:
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email),
// ...
}.Concat(roles.Select(r => new Claim(ClaimTypes.Role, r)));
Once the token is signed, the roles are in it. The API never has to look up roles again on subsequent requests - it validates the token signature and reads the role claims straight out of it. That is what makes JWT auth stateless.
Validating the token
AddJwtBearer is configured with the usual TokenValidationParameters - validate issuer, audience, lifetime, and signing key - all pulled from a bound JwtOptions. One non-default touch: the demo reads the token from a cookie instead of the Authorization header:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
context.Token = context.Request.Cookies["ACCESS_TOKEN"];
return Task.CompletedTask;
}
};
Gating endpoints
With roles in the validated token, authorization is declarative:
app.MapGet("/api/movies", () => /* ... */).RequireAuthorization();
app.MapPatch("/api/salary", () => /* ... */)
.RequireAuthorization(policy => policy.RequireRole("Admin", "HrManager"));
RequireAuthorization() with no arguments means "any authenticated caller". RequireRole(...) means the token must carry at least one of the listed role claims, or the request gets a 403 Forbidden (note: 403, not 401 - the caller is authenticated, just not permitted).
Refresh tokens in HttpOnly cookies
The access token is deliberately short-lived. When it expires, the client calls /api/account/refresh, which reads the refresh token from the REFRESH_TOKEN cookie, looks up the user by that token, checks it has not expired, then issues a fresh access token and a fresh refresh token (rotation) and writes both back as cookies.
The cookies are written with HttpOnly = true, Secure = true, and SameSite = Strict. HttpOnly keeps JavaScript from reading the token, which is the main defense against token theft via XSS. This is why the whole flow uses cookies rather than returning the token in the response body for the SPA to store in localStorage.
Common pitfalls
- Changing a user's roles mid-session. The old token still carries the old roles until it expires. Keep access tokens short and let refresh pick up the change, or maintain a revocation list.
- Expecting
401for a role failure. Authenticated-but-forbidden is403. - Storing tokens in
localStorage. Readable by any script on the page. HttpOnly cookies are the safer default, at the cost of needing CSRF protection. - Not rotating refresh tokens. Issuing the same refresh token repeatedly means a stolen one is valid until natural expiry. Rotate on every use.
- Seeding roles from startup code. Prefer
HasDataso roles are part of the schema, not a runtime side effect.
Key Takeaways
- ASP.NET Core Identity stores users and roles;
IdentityDbContext<User, IdentityRole<Guid>, Guid>gives you the schema with Guid keys. - Seed roles as reference data with
HasDataso they exist from the first migration. - On login, write each role as a
ClaimTypes.Roleclaim into the JWT - the API then reads roles from the token, never the database. - Gate endpoints with
RequireAuthorization()for any authenticated user andRequireRole(...)for specific roles; a role failure is403. - Store access and refresh tokens in
HttpOnly,Secure,SameSite=Strictcookies and rotate the refresh token on every use. - Keep access tokens short so role changes take effect quickly.
Get the Full Source Code
The complete runnable solution - the Identity setup, the seeded roles, the JWT token processor, the refresh endpoint with rotation, and the role-gated endpoints - is available to Patreon supporters. If you want to register users, assign roles, and watch 403s happen instead of rebuilding it from the walkthrough above, you can find it on Patreon.