ASP.NET Core Identity provides a solid foundation for user management, but the defaults rarely survive contact with real requirements. You'll need custom user properties, different password rules, alternative storage backends, or custom token generation. The framework is designed for exactly this kind of extension.
Custom User Properties
The default IdentityUser covers basics like email and phone number. Extend it to add your own properties:
public class ApplicationUser : IdentityUser
{
public string DisplayName { get; set; } = string.Empty;
public DateTime DateOfBirth { get; set; }
public string? AvatarUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
Update your DbContext to use the custom user type:
public class AppDbContext : IdentityDbContext<ApplicationUser>
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
}
Register Identity with the custom type:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Configuration here
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
Custom Password Policies
The default password requirements are reasonable, but you'll often need to adjust them:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 12;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredUniqueChars = 4;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
This follows NIST SP 800-63B guidelines, which recommend longer minimum lengths over complexity requirements. Users create better passwords when they're not forced to include arbitrary character types.
For more sophisticated validation, implement a custom IPasswordValidator<T>:
public class CommonPasswordValidator : IPasswordValidator<ApplicationUser>
{
private static readonly HashSet<string> CommonPasswords =
new(File.ReadAllLines("common-passwords.txt"),
StringComparer.OrdinalIgnoreCase);
public Task<IdentityResult> ValidateAsync(
UserManager<ApplicationUser> manager,
ApplicationUser user,
string? password)
{
if (password is not null && CommonPasswords.Contains(password))
{
return Task.FromResult(IdentityResult.Failed(
new IdentityError
{
Code = "CommonPassword",
Description = "This password is too common. Please choose a different one."
}));
}
return Task.FromResult(IdentityResult.Success);
}
}
Register it:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddPasswordValidator<CommonPasswordValidator>()
.AddDefaultTokenProviders();
Multiple password validators run in sequence. The built-in validator and your custom one both apply.
Lockout Configuration
Protect against brute-force attacks by configuring account lockout:
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// In your login handler, check for lockout
var result = await signInManager.PasswordSignInAsync(
model.Email,
model.Password,
model.RememberMe,
lockoutOnFailure: true); // Important: must be true
if (result.IsLockedOut)
{
return Results.Problem(
"Account locked due to too many failed attempts. Try again later.",
statusCode: 429);
}
Custom User Claims
Add claims during sign-in that are available throughout the request:
public class CustomClaimsPrincipalFactory
: UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
public CustomClaimsPrincipalFactory(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> options)
: base(userManager, roleManager, options) { }
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(
ApplicationUser user)
{
var identity = await base.GenerateClaimsAsync(user);
identity.AddClaim(new Claim("display_name", user.DisplayName));
identity.AddClaim(new Claim("avatar_url", user.AvatarUrl ?? ""));
identity.AddClaim(new Claim("member_since",
user.CreatedAt.ToString("yyyy-MM-dd")));
return identity;
}
}
Register it:
builder.Services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>,
CustomClaimsPrincipalFactory>();
Custom User Store
If you're not using Entity Framework — perhaps you have an existing database or a different ORM — implement IUserStore<T>:
public class DapperUserStore : IUserStore<ApplicationUser>,
IUserPasswordStore<ApplicationUser>,
IUserEmailStore<ApplicationUser>
{
private readonly IDbConnection _db;
public DapperUserStore(IDbConnection db) => _db = db;
public async Task<IdentityResult> CreateAsync(
ApplicationUser user, CancellationToken cancellationToken)
{
await _db.ExecuteAsync(
"INSERT INTO Users (Id, UserName, Email, PasswordHash, DisplayName) " +
"VALUES (@Id, @UserName, @Email, @PasswordHash, @DisplayName)",
user);
return IdentityResult.Success;
}
public async Task<ApplicationUser?> FindByEmailAsync(
string normalisedEmail, CancellationToken cancellationToken)
{
return await _db.QuerySingleOrDefaultAsync<ApplicationUser>(
"SELECT * FROM Users WHERE NormalisedEmail = @Email",
new { Email = normalisedEmail });
}
// ... implement remaining interface methods
}
Register the custom store:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddUserStore<DapperUserStore>()
.AddDefaultTokenProviders();
Wrapping Up
ASP.NET Core Identity is designed as a set of composable pieces. You can replace the user type, password validation, claims generation, and the entire storage layer independently. Start with the defaults, identify where they don't fit your requirements, and swap out just those pieces. The framework handles the orchestration.