Security and authentication are one of the most important aspects of any application. Understanding and applying proven tools is critical to prevent common vulnerabilities such as unauthorized access and data leaks.
ASP.NET Core Identity offers developers a powerful way to manage users, roles, claims, and perform user authentication for web apps. Identity provides ready-to-use solutions and APIs out of the box. But often you need to make adjustments to fit your specific needs or requirements.
Today I want to show you practical approaches to customizing ASP.NET Identity step-by-step.
We will explore:
- How to adapt the built-in Identity tables to your database schema
- How to register and log users with JWT tokens
- How to update user roles and claims with Identity
- How to seed initial roles and claims using Identity.
Let's dive in!
ASP.NET Core Identity is a set of tools that adds login functionality to ASP.NET Core applications. It handles tasks like creating new users, hashing passwords, validating user credentials, and managing roles or claims.
Add the following packages to your project to get started with ASP.NET Core Identity:
bashdotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package Microsoft.EntityFrameworkCore dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
Here is how you can configure the default Identity:
csharpbuilder.Services.AddDefaultIdentity<IdentityUser>(options => {}) .AddEntityFrameworkStores<ApplicationDbContext>(); builder.Services.Configure<IdentityOptions>(options => { // Password settings. options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireNonAlphanumeric = true; options.Password.RequireUppercase = true; options.Password.RequiredLength = 6; options.Password.RequiredUniqueChars = 1; // Lockout settings. options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; // User settings. options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; options.User.RequireUniqueEmail = false; });
Here is the list of available entities provided by Identity:
- User
- Role
- Claim
- UserClaim
- UserRole
- RoleClaim
- UserToken
- UserLogin
However, in many apps you may need to make the following customizations:
- Add own custom fields to the
User
entity - Add own custom fields to the
Role
entity - Reference user and role child entities when using
IdentityDbContext
- Use JWT Tokens instead of Bearer Tokens (yes, ASP.NET Core Identity returns Bearer tokens when using ready Web APIs)
Let's explore how you can customize the database schema for Identity with EF Core.
One of the great features of ASP.NET Core Identity is that you can customize the entities and their corresponding database tables.
You can create your own class and inherit from the IdentityUser
to add new fields (FullName and JobTitle):
csharppublic class User : IdentityUser { public string FullName { get; set; } public string JobTitle { get; set; } }
Next you need to inherit from the IdentityDbContext
and specify the type of your custom user class:
csharppublic class BooksDbContext : IdentityDbContext<User> { }
You can reference a User entity in other entities just as usual:
csharppublic class Author { public required Guid Id { get; set; } public required string Name { get; set; } public List<Book> Books { get; set; } = []; public string? UserId { get; set; } public User? User { get; set; } } public class AuthorConfiguration : IEntityTypeConfiguration<Author> { public void Configure(EntityTypeBuilder<Author> builder) { builder.ToTable("authors"); builder.HasKey(x => x.Id); // ... builder.HasOne(x => x.User) .WithOne() .HasForeignKey<Author>(x => x.UserId); } }
Let's extend a User
entity further to be able to navigate to user's claims, roles, logins and tokens:
csharppublic class User : IdentityUser { public ICollection<UserClaim> Claims { get; set; } public ICollection<UserRole> UserRoles { get; set; } public ICollection<UserLogin> UserLogins { get; set; } public ICollection<UserToken> UserTokens { get; set; } }
We need to override the mapping for the User
entity to specify the relationships:
csharppublic class UserConfiguration : IEntityTypeConfiguration<User> { public void Configure(EntityTypeBuilder<User> builder) { // Each User can have many UserClaims builder.HasMany(e => e.Claims) .WithOne(e => e.User) .HasForeignKey(uc => uc.UserId) .IsRequired(); // Each User can have many UserLogins builder.HasMany(e => e.UserLogins) .WithOne(e => e.User) .HasForeignKey(ul => ul.UserId) .IsRequired(); // Each User can have many UserTokens builder.HasMany(e => e.UserTokens) .WithOne(e => e.User) .HasForeignKey(ut => ut.UserId) .IsRequired(); // Each User can have many entries in the UserRole join table builder.HasMany(e => e.UserRoles) .WithOne(e => e.User) .HasForeignKey(ur => ur.UserId) .IsRequired(); } }
If you want to have all the relations between Identity entities, you need to extend other classes too:
csharppublic class Role : IdentityRole { public ICollection<UserRole> UserRoles { get; set; } public ICollection<RoleClaim> RoleClaims { get; set; } } public class RoleClaim : IdentityRoleClaim<string> { public Role Role { get; set; } } public class UserRole : IdentityUserRole<string> { public User User { get; set; } public Role Role { get; set; } } public class UserClaim : IdentityUserClaim<string> { public User User { get; set; } } public class UserLogin : IdentityUserLogin<string> { public User User { get; set; } } public class UserToken : IdentityUserToken<string> { public User User { get; set; } }
Here is the mapping for the entities:
csharppublic class RoleConfiguration : IEntityTypeConfiguration<Role> { public void Configure(EntityTypeBuilder<Role> builder) { builder.ToTable("roles"); // Each Role can have many entries in the UserRole join table builder.HasMany(e => e.UserRoles) .WithOne(e => e.Role) .HasForeignKey(ur => ur.RoleId) .IsRequired(); // Each Role can have many associated RoleClaims builder.HasMany(e => e.RoleClaims) .WithOne(e => e.Role) .HasForeignKey(rc => rc.RoleId) .IsRequired(); } } public class RoleClaimConfiguration : IEntityTypeConfiguration<RoleClaim> { public void Configure(EntityTypeBuilder<RoleClaim> builder) { builder.ToTable("role_claims"); } } public class UserRoleConfiguration : IEntityTypeConfiguration<UserRole> { public void Configure(EntityTypeBuilder<UserRole> builder) { builder.HasKey(x => new { x.UserId, x.RoleId }); } } public class UserClaimConfiguration : IEntityTypeConfiguration<UserClaim> { public void Configure(EntityTypeBuilder<UserClaim> builder) { builder.ToTable("user_claims"); } } public class UserLoginConfiguration : IEntityTypeConfiguration<UserLogin> { public void Configure(EntityTypeBuilder<UserLogin> builder) { builder.ToTable("user_logins"); } } public class UserTokenConfiguration : IEntityTypeConfiguration<UserToken> { public void Configure(EntityTypeBuilder<UserToken> builder) { builder.ToTable("user_tokens"); } }
It may seem much, but you need to get done this once and then use it in every project.
P.S.: you can download the full source code at the end of the article.
Here is how the BooksDbContext
changes:
csharppublic class BooksDbContext : IdentityDbContext<User, Role, string, UserClaim, UserRole, UserLogin, RoleClaim, UserToken> { }
Finally, you need to register Identity in DI:
csharpservices.AddDbContext<BooksDbContext>((provider, options) => { options .UseNpgsql(connectionString, npgsqlOptions => { npgsqlOptions.MigrationsHistoryTable(DatabaseConsts.MigrationTableName, DatabaseConsts.Schema); }) .UseSnakeCaseNamingConvention(); }); services .AddIdentity<User, Role>(options => { options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = true; options.Password.RequiredLength = 8; }) .AddEntityFrameworkStores<BooksDbContext>() .AddSignInManager() .AddDefaultTokenProviders();
By default, all the tables and columns in the database will be in PascalCase.
If you prefer consistent and automatic naming patterns, consider the package EFCore.NamingConventions
:
bashdotnet add package EFCore.NamingConventions
Once installed, you simply need to plug in the naming convention support into your DbContext configuration.
In my DbContext registration above I used UseSnakeCaseNamingConvention()
for my Postgres database.
So the table and column names would look like: user_claims
, user_id
, etc.
Now that Identity is wired up, you can expose an endpoint to let new users register. Here is a Minimal API example:
csharppublic record RegisterUserRequest(string Email, string Password); app.MapPost("/api/register", async ( [FromBody] RegisterUserRequest request, UserManager<User> userManager) => { var existingUser = await userManager.FindByEmailAsync(request.Email); if (existingUser != null) { return Results.BadRequest("User already exists."); } var user = new User { UserName = request.Email, Email = request.Email }; var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded) { return Results.BadRequest(result.Errors); } result = await userManager.AddToRoleAsync(user, "DefaultRole"); if (!result.Succeeded) { return Results.BadRequest(result.Errors); } var response = new UserResponse(user.Id, user.Email); return Results.Created($"/api/users/{user.Id}", response); });
You can use the UserManager<User>
class to manage users.
Registration involves the following steps:
- You need to check if the user already exists by calling
FindByEmailAsync
method. - If the user does not exist, you can create a new user by calling
CreateAsync
method. - You can add the user to a role by calling
AddToRoleAsync
method. - In case of error during registration - you can return a
BadRequest
response with the error message from Identity.
Once a user is created, they can authenticate. Here is how you can implement authentication using Identity:
csharpusing Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; public record LoginUserRequest(string Email, string Password); app.MapPost("/api/login", async ( [FromBody] LoginUserRequest request, IOptions<AuthConfiguration> authOptions, UserManager<User> userManager, SignInManager<User> signInManager, RoleManager<Role> roleManager) => { var user = await userManager.FindByEmailAsync(request.Email); if (user is null) { return Results.NotFound("User not found"); } var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, false); if (!result.Succeeded) { return Results.Unauthorized(); } var roles = await userManager.GetRolesAsync(user); var userRole = roles.FirstOrDefault() ?? "user"; var role = await roleManager.FindByNameAsync(userRole); var roleClaims = role is not null ? await roleManager.GetClaimsAsync(role) : []; var token = GenerateJwtToken(user, authOptions.Value, userRole, roleClaims); return Results.Ok(new { Token = token }); });
The authentication process involves the following steps:
- You need to check if the user exists by calling
FindByEmailAsync
method. - You can check the user's password by calling
CheckPasswordSignInAsync
method fromSignInManager<User>
. - You can get the user's roles by calling
GetRolesAsync
method fromUserManager<User>
. - You can get the role's claims by calling
GetClaimsAsync
method fromRoleManager<Role>
. - In case of error during login - you can return a
BadRequest
response with the error message from Identity.
You can issue a JWT token upon successful login:
csharpprivate static string GenerateJwtToken(User user, AuthConfiguration authConfiguration, string userRole, IList<Claim> roleClaims) { var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authConfiguration.Key)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); List<Claim> claims = [ new(JwtRegisteredClaimNames.Sub, user.Email!), new("userid", user.Id), new("role", userRole) ]; foreach (var roleClaim in roleClaims) { claims.Add(new Claim(roleClaim.Type, roleClaim.Value)); } var token = new JwtSecurityToken( issuer: authConfiguration.Issuer, audience: authConfiguration.Audience, claims: claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); }
Each role in my application has a set of claims, I add these claims to the JWT token. Here is what an issued JWT token may look like:
csharp{ "sub": "[email protected]", "userid": "dc233fac-bace-4719-9a4f-853e199300d5", "role": "Admin", "users:create": "true", "users:update": "true", "users:delete": "true", "books:create": "true", "books:update": "true", "books:delete": "true", "exp": 1739481834, "iss": "DevTips", "aud": "DevTips" }
You can use the claims to limit access to the endpoints, for example:
csharpapp.MapPost("/api/books", Handle) .RequireAuthorization("books:create"); app.MapDelete("/api/books/{id}", Handle) .RequireAuthorization("books:delete"); app.MapPost("/api/users", Handle) .RequireAuthorization("users:create"); app.MapDelete("/api/users/{id}", Handle) .RequireAuthorization("users:delete");
I explained how to set up Authentication and Authorization in ASP.NET Core here.
Seeding is helpful when you want to set up an application with default roles and claims. A common approach is to seed data once on the application startup:
csharpvar app = builder.Build(); // Register middlewares... // Create and seed database using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService<BooksDbContext>(); var userManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>(); var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>(); await DatabaseSeedService.SeedAsync(dbContext, userManager, roleManager); } await app.RunAsync();
csharppublic static class DatabaseSeedService { public static async Task SeedAsync(BooksDbContext dbContext, UserManager<User> userManager, RoleManager<Role> roleManager) { await dbContext.Database.MigrateAsync(); if (await dbContext.Users.AnyAsync()) { return; } // Seed roles and claims here await dbContext.SaveChangesAsync(); } }
You can use the RoleManager<Role>
to manage roles and their claims:
csharpvar adminRole = new Role { Name = "Admin" }; var authorRole = new Role { Name = "Author" }; var result = await roleManager.CreateAsync(adminRole); result = await roleManager.CreateAsync(authorRole); result = await roleManager.AddClaimAsync(adminRole, new Claim("users:create", "true")); result = await roleManager.AddClaimAsync(adminRole, new Claim("users:update", "true")); result = await roleManager.AddClaimAsync(adminRole, new Claim("users:delete", "true")); result = await roleManager.AddClaimAsync(adminRole, new Claim("books:create", "true")); result = await roleManager.AddClaimAsync(adminRole, new Claim("books:update", "true")); result = await roleManager.AddClaimAsync(adminRole, new Claim("books:delete", "true")); result = await roleManager.AddClaimAsync(authorRole, new Claim("books:create", "true")); result = await roleManager.AddClaimAsync(authorRole, new Claim("books:update", "true")); result = await roleManager.AddClaimAsync(authorRole, new Claim("books:delete", "true"));
Here is how you can create a default user in your application:
csharpvar adminUser = new User { Id = Guid.NewGuid().ToString(), Email = "[email protected]", UserName = "[email protected]" }; result = await userManager.CreateAsync(adminUser, "Test1234!"); result = await userManager.AddToRoleAsync(adminUser, "Admin");
It's important to change the default password after you are successfully logged in for the first time.
Hope you find this newsletter useful. See you next time.