ASP.NET Core Identity Setup — Users, Passwords, and Stores
Configure ASP.NET Core Identity correctly: custom user entity, password hashing, EF Core store, token providers, and the identity pipeline in a Clean Architecture project.
What ASP.NET Core Identity Provides
ASP.NET Core Identity is a membership system: user storage, password hashing, role management, token providers (email confirmation, password reset), and lockout. It is infrastructure — it belongs in the Infrastructure layer of a Clean Architecture project.
Identity provides:
✓ UserManager — create, find, update, delete users
✓ SignInManager — password check, lockout tracking, 2FA
✓ RoleManager — role CRUD
✓ IPasswordHasher — PBKDF2 password hashing
✓ Token providers — email confirmation, password reset, TOTP 2FA
Identity does NOT provide:
✗ JWT issuance — you write that
✗ OAuth flows — add IdentityServer/Duende or use Azure AD
✗ Session management — you handle that Custom User Entity
// Domain/Users/AppUser.cs
public sealed class AppUser : IdentityUser<Guid>
{
// IdentityUser provides: Id, UserName, Email, PasswordHash,
// PhoneNumber, LockoutEnabled, AccessFailedCount, etc.
// Add domain-specific fields:
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Department { get; set; } = null!;
public string LicenseNo { get; set; } = null!; // medical license
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}Registration with EF Core Store
// Infrastructure/Persistence/AppDbContext.cs
public sealed class AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder); // Identity table configuration
// Rename Identity tables to match your naming convention
builder.Entity<AppUser>().ToTable("Users");
builder.Entity<AppRole>().ToTable("Roles");
builder.Entity<IdentityUserRole<Guid>>().ToTable("UserRoles");
builder.Entity<IdentityUserClaim<Guid>>().ToTable("UserClaims");
builder.Entity<IdentityUserLogin<Guid>>().ToTable("UserLogins");
builder.Entity<IdentityUserToken<Guid>>().ToTable("UserTokens");
builder.Entity<IdentityRoleClaim<Guid>>().ToTable("RoleClaims");
}
}// Program.cs
builder.Services
.AddIdentity<AppUser, AppRole>(options =>
{
// Password policy
options.Password.RequiredLength = 12;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
// Lockout
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
// User settings
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();User Registration Handler
// Application/Auth/Commands/RegisterUser/RegisterUserHandler.cs
public sealed class RegisterUserHandler
{
private readonly UserManager<AppUser> _users;
public RegisterUserHandler(UserManager<AppUser> users) => _users = users;
public async Task<Result<Guid>> Handle(
RegisterUserCommand cmd, CancellationToken ct)
{
var existing = await _users.FindByEmailAsync(cmd.Email);
if (existing is not null)
return Result.Failure<Guid>(AuthErrors.EmailAlreadyTaken);
var user = new AppUser
{
Id = Guid.NewGuid(),
UserName = cmd.Email,
Email = cmd.Email,
FirstName = cmd.FirstName,
LastName = cmd.LastName,
Department = cmd.Department,
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
var result = await _users.CreateAsync(user, cmd.Password);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Result.Failure<Guid>(Error.Validation("Identity.CreateFailed", errors));
}
await _users.AddToRoleAsync(user, cmd.Role);
return Result.Success(user.Id);
}
}Login with Lockout
// Application/Auth/Commands/Login/LoginHandler.cs
public sealed class LoginHandler
{
private readonly UserManager<AppUser> _users;
private readonly SignInManager<AppUser> _signIn;
private readonly TokenService _tokens;
public async Task<Result<LoginResponse>> Handle(
LoginCommand cmd, CancellationToken ct)
{
var user = await _users.FindByEmailAsync(cmd.Email);
if (user is null || !user.IsActive)
return Result.Failure<LoginResponse>(AuthErrors.InvalidCredentials);
// lockoutOnFailure: true — increments failed attempts, locks after threshold
var result = await _signIn.CheckPasswordSignInAsync(
user, cmd.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
return Result.Failure<LoginResponse>(AuthErrors.AccountLocked);
if (!result.Succeeded)
return Result.Failure<LoginResponse>(AuthErrors.InvalidCredentials);
var accessToken = _tokens.GenerateAccessToken(user);
var refreshToken = await _tokens.CreateRefreshTokenAsync(user.Id, cmd.IpAddress);
return Result.Success(new LoginResponse(accessToken, refreshToken.Token));
}
}Password Reset Flow
// Step 1: Generate reset token
var user = await _users.FindByEmailAsync(email);
var token = await _users.GeneratePasswordResetTokenAsync(user!);
// Send token via email (URL-encode it)
await _email.SendPasswordResetAsync(user.Email, token);
// Step 2: User submits new password with token
var result = await _users.ResetPasswordAsync(user!, resetToken, newPassword);
if (!result.Succeeded)
return Result.Failure(AuthErrors.InvalidResetToken);Email Confirmation
// After registration, send confirmation email
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
var link = $"https://app.systemforge.com/confirm-email?token={Uri.EscapeDataString(token)}&userId={user.Id}";
await _email.SendConfirmationAsync(user.Email, link);
// On confirmation endpoint
var result = await _users.ConfirmEmailAsync(user!, token);
if (!result.Succeeded)
return Results.BadRequest("Invalid confirmation token.");Production issue I've seen: A team did not URL-encode the email confirmation token before embedding it in the link. Tokens containing
+characters (common in Base64) were decoded as spaces by the browser, making the token invalid.Uri.EscapeDataString()on the token before embedding it in a URL fixes this entirely.
Clean Architecture Layer Placement
Domain:
AppUser extends IdentityUser
No Identity interfaces here — just the entity
Application:
Uses UserManager and SignInManager via DI
Handlers: RegisterUser, Login, ChangePassword, ResetPassword
Does NOT reference EF Core directly
Infrastructure:
AppDbContext : IdentityDbContext
EF Core migration for Identity tables
Token providers configuration
API:
Endpoints that call application handlers
No Identity calls directly in endpoints Red Flag / Green Answer
Red Flag: "We call _users.CreateAsync() directly from the API controller."
Identity calls in controllers bypass the application layer — no domain validation, no Result pattern, no consistent error handling. When requirements change (add department validation, enforce license number), the logic is scattered across controllers.
Green Answer:
RegisterUserHandlerin Application layer. Controller calls_handler.Handle(cmd). Identity is an infrastructure detail — the application layer orchestrates it.
Key Takeaway
ASP.NET Core Identity is a complete membership system: hashed passwords, lockout, token providers. In Clean Architecture, it lives in Infrastructure (EF store) and is used by Application handlers (via UserManager/SignInManager). The Identity tables belong to your DbContext; your custom AppUser adds domain fields. JWT issuance is separate — Identity validates credentials, your TokenService issues tokens.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.