DEV Community

Cover image for How to Customize ASP.NET Core Identity With EF Core for Your Project Needs
Anton Martyniuk
Anton Martyniuk

Posted on • Originally published at antondevtips.com on

1

How to Customize ASP.NET Core Identity With EF Core for Your Project Needs

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!

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Getting Started with ASP.NET Identity

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:

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Here is how you can configure the default Identity:

builder.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;
});
Enter fullscreen mode Exit fullscreen mode

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.

Customizing ASP.NET 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):

public class User : IdentityUser
{
    public string FullName { get; set; }

    public string JobTitle { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Next you need to inherit from the IdentityDbContext and specify the type of your custom user class:

public class BooksDbContext : IdentityDbContext<User>
{
}
Enter fullscreen mode Exit fullscreen mode

You can reference a User entity in other entities just as usual:

public 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's extend a User entity further to be able to navigate to user's claims, roles, logins and tokens:

public 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; }
}
Enter fullscreen mode Exit fullscreen mode

We need to override the mapping for the User entity to specify the relationships:

public 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

If you want to have all the relations between Identity entities, you need to extend other classes too:

public 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; }
}
Enter fullscreen mode Exit fullscreen mode

Here is the mapping for the entities:

public 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

public class BooksDbContext : IdentityDbContext<User, Role, string,
    UserClaim, UserRole, UserLogin,
    RoleClaim, UserToken>
{
}
Enter fullscreen mode Exit fullscreen mode

Finally, you need to register Identity in DI:

services.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();
Enter fullscreen mode Exit fullscreen mode

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:

dotnet add package EFCore.NamingConventions
Enter fullscreen mode Exit fullscreen mode

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.

Registering Users with Identity

Now that Identity is wired up, you can expose an endpoint to let new users register.
Here is a Minimal API example:

public 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);
});
Enter fullscreen mode Exit fullscreen mode

You can use the UserManager<User> class to manage users.

Registration involves the following steps:

  1. You need to check if the user already exists by calling FindByEmailAsync method.
  2. If the user does not exist, you can create a new user by calling CreateAsync method.
  3. You can add the user to a role by calling AddToRoleAsync method.
  4. In case of error during registration - you can return a BadRequest response with the error message from Identity.

How to Log in Users with Identity

Once a user is created, they can authenticate.
Here is how you can implement authentication using Identity:

using 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 });
});
Enter fullscreen mode Exit fullscreen mode

The authentication process involves the following steps:

  1. You need to check if the user exists by calling FindByEmailAsync method.
  2. You can check the user's password by calling CheckPasswordSignInAsync method from SignInManager<User>.
  3. You can get the user's roles by calling GetRolesAsync method from UserManager<User>.
  4. You can get the role's claims by calling GetClaimsAsync method from RoleManager<Role>.
  5. 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:

private 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);
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "sub": "admin@test.com",
  "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"
}
Enter fullscreen mode Exit fullscreen mode

You can use the claims to limit access to the endpoints, for example:

app.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");
Enter fullscreen mode Exit fullscreen mode

I explained how to set up Authentication and Authorization in ASP.NET Core here.

How to Seed Identity Data: Initialize Roles and Claims

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:

var 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();
Enter fullscreen mode Exit fullscreen mode
public 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use the RoleManager<Role> to manage roles and their claims:

var 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"));
Enter fullscreen mode Exit fullscreen mode

Here is how you can create a default user in your application:

var adminUser = new User
{
    Id = Guid.NewGuid().ToString(),
    Email = "admin@test.com",
    UserName = "admin@test.com"
};

result = await userManager.CreateAsync(adminUser, "Test1234!");
result = await userManager.AddToRoleAsync(adminUser, "Admin");
Enter fullscreen mode Exit fullscreen mode

It's important to change the default password after you are successfully logged in for the first time.

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Google AI Education track image

Build Apps with Google AI Studio 🧱

This track will guide you through Google AI Studio's new "Build apps with Gemini" feature, where you can turn a simple text prompt into a fully functional, deployed web application in minutes.

Read more →

Top comments (2)

Collapse
 
michael_liang_0208 profile image
Michael Liang

Nice post

Collapse
 
antonmartyniuk profile image
Anton Martyniuk

Thank you

👋 Kindness is contagious

Explore this insightful write-up, celebrated by our thriving DEV Community. Developers everywhere are invited to contribute and elevate our shared expertise.

A simple "thank you" can brighten someone’s day—leave your appreciation in the comments!

On DEV, knowledge-sharing fuels our progress and strengthens our community ties. Found this useful? A quick thank you to the author makes all the difference.

Okay