ASP.NET Core Identity — Deep Dive
Key Points
MapIdentityApi<TUser>(covered in Identity Endpoints (MapIdentityApi)) is the endpoints. This file is the plumbing — whatUserManager,SignInManager,IPasswordHasheractually do, how token providers work, and what your migration paths look like.- The class hierarchy you'll inherit:
IdentityUser/IdentityRole/IdentityDbContext. Default primary key isstring(GUID); switching toGuidorintis a generic-parameter-wide change. UserManager<TUser>is the workhorse. All user mutations returnIdentityResult— handle errors, don't.Succeededblindly.- Password hashing = PBKDF2-V3 by default (HMAC-SHA512, 100k+ iterations). V2 hashes still readable for migration. No pepper out of the box — Identity hashes the password, doesn't combine it with an app-secret. You add that yourself if you need it.
- 2FA: TOTP (
AuthenticatorTokenProvider) is the default modern approach. SMS is being deprecated industry-wide (cost + SIM-swap). Recovery codes are mandatory for usability. - Don't put profile data in
IdentityUser. Keep it in your domain table joined by user id. Identity is for credentials, not your business model. - When to outgrow Identity: multi-app SSO, OIDC issuer, third-party logins as first class → OpenIddict (open source) or Duende IdentityServer (commercial). Cross-link OAuth2 & OIDC Flows, Identity Providers Comparison.
Concepts (deep dive)
The class hierarchy
IdentityUser<TKey> ← your AppUser inherits this
IdentityRole<TKey> ← your AppRole inherits this (if used)
IdentityUserClaim<TKey>
IdentityUserLogin<TKey> ← external login linkage
IdentityUserToken<TKey> ← password reset, 2FA, refresh tokens
IdentityRoleClaim<TKey>
IdentityUserRole<TKey>
IdentityDbContext<TUser, TRole, TKey> ← your AppDbContext inherits this
TKey defaults to string (a GUID stored as text). Pick consciously:
// String GUID (default) — simplest, works everywhere
public class AppUser : IdentityUser { }
// Int — smaller, joins faster, but reveals user count via enumeration
public class AppUser : IdentityUser<int> { }
// Guid — type-safe, clustered-index friendly with sequential GUID
public class AppUser : IdentityUser<Guid> { }
public class AppDbContext(DbContextOptions<AppDbContext> o)
: IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>(o) { }
Switching key types post-launch is a multi-day data migration. Pick once.
UserManager<TUser> — the workhorse
public async Task<IdentityResult> RegisterAsync(string email, string password)
{
var user = new AppUser { UserName = email, Email = email };
var result = await users.CreateAsync(user, password);
if (!result.Succeeded) return result;
var token = await users.GenerateEmailConfirmationTokenAsync(user);
var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
await emailSender.SendConfirmationLinkAsync(user, email,
$"https://app.x/confirm?id={user.Id}&code={encoded}");
return IdentityResult.Success;
}
IdentityResult is the consistent return type. All errors live in result.Errors (IdentityError with Code like "DuplicateUserName", "PasswordTooShort", plus Description). Don't throw — surface the codes.
Common operations: CreateAsync(user, password) (hashes + persists), AddPasswordAsync (for users created without one, e.g., external login), ChangePasswordAsync (verifies current first), FindByEmailAsync, FindByIdAsync, SetEmailAsync (re-confirmation needed), UpdateSecurityStampAsync (invalidates all cookies), AccessFailedAsync / IsLockedOutAsync, DeleteAsync.
Token providers
AddDefaultTokenProviders() registers four: - Default (DataProtectorTokenProvider) — opaque DPAPI-encrypted tokens for email confirmation, password reset. - Authenticator (AuthenticatorTokenProvider) — 6-digit TOTP codes from a shared secret. - Email / Phone — short numeric codes for the respective medium.
Tokens contain arbitrary bytes — always Base64UrlEncode for URLs:
var token = await users.GeneratePasswordResetTokenAsync(user);
var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
// On validation:
var raw = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(encoded));
var result = await users.ResetPasswordAsync(user, raw, newPassword);
SignInManager<TUser>
Wraps UserManager + cookie/token issuance:
var result = await signIn.PasswordSignInAsync(
req.Email, req.Password, isPersistent: req.RememberMe, lockoutOnFailure: true);
return result switch
{
{ Succeeded: true } => Results.Ok(),
{ RequiresTwoFactor: true } => Results.Ok(new { twoFactorRequired = true }),
{ IsLockedOut: true } => Results.Forbid(),
{ IsNotAllowed: true } => Results.Forbid(), // email not confirmed
_ => Results.Unauthorized()
};
SignInResult flags: Succeeded, RequiresTwoFactor, IsLockedOut, IsNotAllowed. Branch on them; don't collapse to bool. For 2FA: TwoFactorAuthenticatorSignInAsync(code, isPersistent, rememberClient).
RoleManager<TRole> — and why roles aren't the modern path
await roles.CreateAsync(new IdentityRole("Admin"));
await users.AddToRoleAsync(user, "Admin");
await users.RemoveFromRoleAsync(user, "Admin");
var isInRole = await users.IsInRoleAsync(user, "Admin");
Works fine. But for anything beyond a 3-role app, prefer claims + policy-based authorization (cross-link Authorization Policies & Claims). Roles are coarse; policies handle fine-grained permission checks (CanEditOrder, CanRefundUpTo:500).
// Modern: policy with claim requirement
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanRefund", p => p.RequireClaim("permission", "refund"));
});
[Authorize(Policy = "CanRefund")]
public IActionResult Refund(...) { /* ... */ }
Password hashing (IPasswordHasher<TUser>)
Default = PBKDF2-V3: HMAC-SHA512, 100,000+ iterations, 256-bit salt, 256-bit subkey. Format {0x01}{prfId}{iter}{saltLen}{salt}{subkey} base64-encoded. Backward-compatible with V2 (HMAC-SHA1) — V2 hashes verify and automatically rehash to V3 via PasswordVerificationResult.SuccessRehashNeeded.
Argon2id is the modern recommendation (memory-hard, GPU-resistant) but Identity doesn't ship it. Community packages (Konscious.Security.Cryptography, Isopoh.Cryptography.Argon2) provide it; you replace IPasswordHasher<TUser>:
Migrate-on-verify: try Argon2 first; fall back to PBKDF2; on success, rehash to Argon2.
Pepper (app-secret combined with the password before hashing) is not in Identity. Add it via a custom hasher subclass that appends the pepper before delegating to base.HashPassword. Trade-off: pepper rotation = mass rehash; upside = DB-only theft can't crack passwords.
Lockout
o.Lockout.MaxFailedAccessAttempts = 5;
o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
o.Lockout.AllowedForNewUsers = true; // ⚠️ default = true; verify
SignInManager.PasswordSignInAsync(..., lockoutOnFailure: true) increments the counter; result.IsLockedOut flips after the threshold.
Username enumeration prevention: same response shape and timing whether the user exists or not. If the email isn't found, burn equivalent CPU time on a dummy hash before returning Invalid credentials. SignInManager does some constant-time hardening internally; the response shape is yours.
Two-factor authentication
// Enrollment
var key = await users.GetAuthenticatorKeyAsync(user)
?? (await users.ResetAuthenticatorKeyAsync(user), await users.GetAuthenticatorKeyAsync(user)).Item2;
var uri = $"otpauth://totp/MyApp:{user.Email}?secret={key}&issuer=MyApp&digits=6"; // render as QR
// User enters code; verify and enable
if (await users.VerifyTwoFactorTokenAsync(user, "Authenticator", code))
{
await users.SetTwoFactorEnabledAsync(user, true);
var recoveryCodes = await users.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
// ⚠️ Show ONCE — single-use, hashed in AspNetUserTokens, can never re-display
}
Risk-based 2FA is the modern UX: 2FA only on new device / new IP / privileged actions / after a security event — not every login. Use a RememberClient cookie + risk signals.
SMS 2FA is deprecated (NIST, industry). SIM-swap is widespread; telecom routing is weak; TOTP and passkeys are universal. Don't add SMS 2FA to new applications.
External login providers
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme)
.AddGoogle(o => { o.ClientId = "..."; o.ClientSecret = "..."; })
.AddMicrosoftAccount(o => { /* ... */ });
// Callback: try existing link; if absent, create local user and link
var info = await signIn.GetExternalLoginInfoAsync();
var result = await signIn.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, false);
if (!result.Succeeded)
{
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
var user = new AppUser { UserName = email, Email = email, EmailConfirmed = true };
await users.CreateAsync(user);
await users.AddLoginAsync(user, info);
await signIn.SignInAsync(user, false);
}
Stored in AspNetUserLogins (LoginProvider, ProviderKey, UserId). One user can have multiple linked logins.
Email confirmation flow
// Generate
var token = await users.GenerateEmailConfirmationTokenAsync(user);
var encoded = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
var link = $"https://app.x/confirm?id={user.Id}&code={encoded}";
// Validate (POST, not GET — see anti-pattern below)
var raw = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
await users.ConfirmEmailAsync(user, raw);
Anti-pattern: GET /confirm-email?... that mutates state. Email prefetchers, image proxies, and corporate scanners hit the URL and consume the token before the human clicks. Either land on a GET page that says "click to confirm" (POST), or use single-use anti-replay tokens.
Custom user types — where profile data goes
// ✅ Minimal extension: auth-adjacent fields only
public class AppUser : IdentityUser<Guid>
{
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
}
// Profile data lives in a domain table joined by UserId
public class UserProfile
{
public Guid UserId { get; set; }
public string DisplayName { get; set; } = "";
public Address ShippingAddress { get; set; } = null!;
}
Why not stuff it all in IdentityUser? Identity migrations are awkward (generic-typed inheritance); UserManager saves don't know about your domain; replacing Identity later (OpenIddict, Entra) becomes feasible only if IdentityUser is just credentials.
Custom claims pipeline
IUserClaimsPrincipalFactory<TUser> runs when the principal is built (cookie/token issuance). Subclass UserClaimsPrincipalFactory<TUser>, override CreateAsync, add claims to the principal's ClaimsIdentity, return. For per-request enrichment without re-issuing the cookie, use IClaimsTransformation instead.
Growing beyond Identity — OpenIddict / Duende / hosted IdP
Identity is "auth for this one app." Outgrow it when you need multi-app SSO, OIDC issuer (/.well-known/openid-configuration), OAuth2 client-credentials/device/hybrid flows, third-party clients consuming your tokens, or federation/SAML.
| Option | License | Use when |
|---|---|---|
| OpenIddict | Apache 2.0 | Open-source OIDC issuer; modern community standard |
| Duende IdentityServer | Commercial (>$1M rev) | IdentityServer4 successor; mature; commercial support |
| Entra ID / Auth0 / Okta | SaaS | Don't run an identity server; outsource |
Cross-link OAuth2 & OIDC Flows, Identity Providers Comparison.
Migrations
Adding fields to ApplicationUser: standard EF Core migration via dotnet ef migrations add.
From a custom auth system to Identity: stand up Identity tables alongside; backfill users (rehash via IPasswordHasher, or force reset if old hashes are weak like MD5); cut over to SignInManager; drop old tables after a confidence window.
V2 → V3 hashes: automatic via PasswordHasherCompatibilityMode.IdentityV3 (default). V3 hasher reads V2, verifies, returns SuccessRehashNeeded, SignInManager rehashes.
To Argon2id: custom hasher with multi-format read; rehash on successful verify.
Code: correct vs wrong
❌ Wrong: domain data in IdentityUser
public class AppUser : IdentityUser
{
public List<Order> Orders { get; set; } = new(); // domain bleeding into auth
public Address ShippingAddress { get; set; } = null!;
}
✅ Correct: domain in domain tables, joined by UserId
public class AppUser : IdentityUser { /* credentials only */ }
public class UserProfile
{
public Guid UserId { get; set; }
public Address ShippingAddress { get; set; } = null!;
}
❌ Wrong: not invalidating tokens after password change
await users.ChangePasswordAsync(user, current, next);
// Old cookies still valid until expiry. Stolen cookies live on.
✅ Correct: bump security stamp
await users.ChangePasswordAsync(user, current, next);
await users.UpdateSecurityStampAsync(user);
// Existing cookies fail validation on next request (security stamp validation interval default = 30 min)
Even better: shorten SecurityStampValidationInterval for sensitive apps:
builder.Services.Configure<SecurityStampValidatorOptions>(o =>
o.ValidationInterval = TimeSpan.FromMinutes(1));
❌ Wrong: GET-based email confirmation that mutates state
app.MapGet("/confirm", async (string id, string code, UserManager<AppUser> u) =>
{
var user = await u.FindByIdAsync(id);
await u.ConfirmEmailAsync(user!, code);
return Results.Redirect("/welcome");
});
Email-link prefetchers, image proxies, and corporate-firewall scanners hit this URL. Confirmation gets consumed before the human clicks.
✅ Correct: GET shows page; POST confirms
app.MapGet("/confirm", (string id, string code) =>
Results.Content($"<form method='post'>...</form>", "text/html"));
app.MapPost("/confirm", async (ConfirmRequest req, UserManager<AppUser> u) =>
{
var user = await u.FindByIdAsync(req.Id);
var result = await u.ConfirmEmailAsync(user!, req.Code);
return result.Succeeded ? Results.Ok() : Results.BadRequest();
});
❌ Wrong: leaking user existence
var user = await users.FindByEmailAsync(email);
return user is null
? Results.NotFound("No account with that email")
: Results.Ok();
✅ Correct: same response either way
Design patterns for this topic
Pattern 1 — "Identity for credentials, domain for profile"
- Intent: keep
IdentityUserminimal; profile in domain table joined byUserId. - Rationale: swap-out path, migration sanity, separation of concerns.
Pattern 2 — "Custom claims factory for principal enrichment"
- Intent: populate principal with display name, tier, permissions when the cookie/token is issued.
- Rationale: principal carries everything authorization needs without per-request DB hits.
Pattern 3 — "Argon2id custom hasher with PBKDF2 fallback"
- Intent: modern hash algorithm, smooth migration of existing PBKDF2 users.
- Rationale: GPU resistance; no big-bang rehash event.
Pattern 4 — "TOTP 2FA with risk-based prompts"
- Intent: require 2FA on new device / new IP / privileged action — not every login.
- Rationale: security without UX friction.
Pattern 5 — "Cut over to OpenIddict when SSO arrives"
- Intent: treat Identity as a stepping stone; OpenIddict (or Duende) when multi-app SSO becomes a requirement.
Pattern 6 — "Security stamp on every credential mutation"
- Intent: invalidate all sessions when password/email/2FA changes.
- Rationale: stolen cookie window.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| ASP.NET Core Identity | Free, integrated, full control | Self-hosted; you own security |
| OpenIddict | OIDC, free, modern | More complex setup |
| Duende | Mature, commercial support | License cost above revenue threshold |
| Entra/Auth0/Okta | Best security defaults; SSO | Cost; vendor coupling |
| PBKDF2 (default) | BCL, no extra deps | Less GPU-resistant than Argon2id |
| Argon2id | Modern, memory-hard | Custom hasher; community packages |
| TOTP 2FA | Standard, widely supported | Lost-device UX needs recovery codes |
| SMS 2FA | Familiar to users | SIM-swap attacks; cost; deprecated |
| Roles | Simple | Coarse; doesn't fit fine-grained perms |
| Claims + policies | Fine-grained, flexible | More config |
When to use / when to avoid
- Use Identity for self-managed user accounts when you own the user database and don't need OIDC.
- Use OpenIddict when you need to issue OIDC tokens (multiple apps, third-party clients).
- Use Entra/Auth0 when SSO/security maturity matters more than self-hosting.
- Avoid Identity for "log in with Google/Microsoft" as the primary path — at that point you want a hosted IdP.
- Avoid SMS 2FA for new applications.
- Avoid putting profile data in
IdentityUser. - Avoid hand-rolling password hashing when Identity already includes it.
Interview Q&A
Q1. Why is Identity sometimes overkill? For internal tools and small teams, Identity's schema (8+ tables) and operational surface (token providers, lockout, 2FA, email sender) is heavy. For "log in with Microsoft" apps, a hosted IdP eliminates Identity entirely.
Q2. Identity vs managed IdP (Entra/Auth0)? Identity = your user store, your hashes, your DB — full control + full responsibility. Managed IdPs deliver mature MFA, anomaly detection, breach monitoring, compliance reports at a per-user cost with vendor coupling.
Q3. When move from Identity to OpenIddict? When you need to issue OIDC tokens to other apps, OAuth2 client-credentials, .well-known/openid-configuration, or federation. Identity isn't an OIDC issuer; OpenIddict is.
Q4. V2 → V3 password hash migration path? Automatic. Default PasswordHasher<TUser> reads V2, verifies, returns SuccessRehashNeeded. SignInManager rehashes to V3 transparently. CompatibilityMode = IdentityV3 is the default.
Q5. SMS 2FA still recommended? No. NIST deprecated it in 2017; SIM-swap attacks are widespread. Use TOTP authenticator apps; passkeys where supported.
Q6. Custom claim source — where in the pipeline? At principal-creation time: implement IUserClaimsPrincipalFactory<TUser>. Claims baked into the cookie/token. For per-request enrichment, use IClaimsTransformation.
Q7. How prevent username enumeration? Same response shape and timing regardless of user existence. Burn a dummy hash if the user is missing (constant-time). Return success on registration/reset whether or not the email exists. CAPTCHA + rate limiting for high-value targets.
Q8. Why bump security stamp on password change? Cookie validation runs the security-stamp check (default ValidationInterval 30 min). Stamp change invalidates all outstanding cookies. Without it, stolen cookies live until natural expiry.
Q9. Lockout AllowedForNewUsers default? true in modern Identity. Verify your config — misconfiguration leaves brute-force open against new accounts.
Q10. PBKDF2 vs Argon2id? For new apps: Argon2id (memory-hard, GPU-resistant). For existing PBKDF2: migrate incrementally via custom hasher with rehash-on-verify. Long-term posture, not day-zero risk reduction.
Q11. Profile data — IdentityUser or domain? Domain. Identity = credentials (username, hash, email, 2FA state, lockout). Domain table joins on UserId. Cleaner migrations; swap-out path; separation of concerns.
Q12. Email confirmation token internals? DataProtectorTokenProvider encrypts (DPAPI) a payload of user id, purpose, expiration, security stamp. Opaque, time-bound, stamp-bound — changing the stamp invalidates outstanding tokens.
Q13. Recovery codes — generated and stored? GenerateNewTwoFactorRecoveryCodesAsync(user, n) issues n random codes, hashes each, stores hashes in AspNetUserTokens. Plaintext returned once; never re-displayed; single-use.
Q14. Log-in-with-Google on top of Identity? .AddGoogle(...) for the handler. On callback: signIn.GetExternalLoginInfoAsync() → ExternalLoginSignInAsync(provider, key, ...) if linked; otherwise CreateAsync a local user + AddLoginAsync(user, info). Stored in AspNetUserLogins.
Q15. What's IUserStore? Persistence abstraction under UserManager. Default = UserStore<TUser> (EF). Custom stores for Mongo/Cosmos/Dapper exist; most teams stay on EF — custom stores are real ops cost.
Gotchas / common mistakes
- ⚠️ Profile data in
IdentityUser— couples domain to auth, complicates future migrations. - ⚠️ Not invalidating cookies after password change — bump security stamp; consider shortening
ValidationInterval. - ⚠️
AllowedForNewUsers = false— silent brute-force window. Verify your lockout config. - ⚠️ GET-based email confirmation — prefetchers/scanners consume tokens. Use POST or two-step.
- ⚠️ Username enumeration — different responses or timings leak existence. Same response shape, constant time.
- ⚠️ Missing recovery codes — user loses authenticator; account unrecoverable. Always generate + display recovery codes on 2FA enrollment.
- ⚠️ SMS 2FA in new code — SIM-swap, deprecated.
- ⚠️ Long-lived cookies without sliding expiration — silent compromise window.
- ⚠️ Roles for fine-grained permissions — use claims + policies; roles for coarse only.
- ⚠️ Storing tokens in localStorage — XSS-readable. HttpOnly cookies (cross-link Authentication Fundamentals).
- ⚠️ Not URL-encoding tokens —
DataProtectorTokenProvidertokens contain bytes that break in query strings; alwaysBase64UrlEncode. - ⚠️ Forgetting
IEmailSender<TUser>— registration "works" but no mail sent; users stuck unconfirmed. - ⚠️ Hand-rolled PBKDF2 — when Identity already does it correctly with peer review and rotation. Use the BCL.