In-Depth Guide · 10 Sections
ASP.NET Core Identity In Depth
Everything about membership, authentication and authorization in .NET — from UserManager and cookie login to JWT, roles, claims, Google OAuth, 2FA and production hardening. Plain English, copy-paste .NET 9 code.
Identity + EF Core
JWT & Cookies
Roles & Claims
2FA & OAuth
Prerequisite: basic ASP.NET Core — see Minimal API for Dummies first if you're new to web APIs.
For a quick overview, section 10 of Complete C# has a shorter Identity intro.
ASP.NET Core Identity is Microsoft's membership system for .NET web apps. It handles user accounts, password hashing, login sessions, roles, claims, lockout, two-factor authentication, and external providers (Google, Microsoft, GitHub).
You don't write password hashing, token generation, or database schema from scratch — Identity gives you battle-tested building blocks via UserManager, SignInManager, and RoleManager.
1.1
Identity vs rolling your own
// ❌ Roll your own (don't)
// - Store passwords as MD5/SHA1 (broken)
// - Invent your own session tokens
// - Forget lockout after 5 failed attempts
// - Miss email confirmation flows
// ✅ ASP.NET Core Identity gives you:
// - PBKDF2 password hashing (configurable)
// - Cookie auth + optional JWT
// - Account lockout, 2FA, external logins
// - EF Core stores (SQL Server, PostgreSQL, SQLite)
// - Extensible: custom users, claims, validators
1.2
Cookie auth vs JWT — when to use which
Cookies for server-rendered apps (Razor Pages, MVC, Blazor Server). JWT for SPAs, mobile apps, and APIs where the client stores the token.
// Cookie (browser sends automatically)
// - HttpOnly, Secure, SameSite flags
// - Great for same-site web apps
// - Use SignInManager.PasswordSignInAsync
// JWT (client sends Authorization header)
// - Stateless API authentication
// - Token expires — use refresh tokens for long sessions
// - Use JwtSecurityTokenHandler + AddJwtBearer
2.1
Core managers & stores
// UserManager<TUser> — create/update/delete users, passwords, claims, roles
// SignInManager<TUser> — sign in/out, 2FA, external login linking
// RoleManager<TRole> — create roles, assign to users
// IUserStore<TUser> — persistence (EF Core implements this)
// IUserPasswordStore — password hash storage
// IUserRoleStore — user-role mapping
// IUserClaimStore — custom claims per user
// IUserLoginStore — external provider keys (Google sub, etc.)
// All injected via DI — never new UserManager() yourself
2.2
Database tables (EF Core default schema)
// AspNetUsers — Id, UserName, Email, PasswordHash, LockoutEnd, ...
// AspNetRoles — Id, Name
// AspNetUserRoles — UserId, RoleId (many-to-many)
// AspNetUserClaims — UserId, ClaimType, ClaimValue
// AspNetRoleClaims — RoleId, ClaimType, ClaimValue
// AspNetUserLogins — external provider logins (ProviderKey, ProviderDisplayName)
// AspNetUserTokens — password reset, email confirm, 2FA recovery codes
// Customize table names in OnModelCreating if needed:
// builder.Entity<ApplicationUser>().ToTable("Users");
2.3
Authentication vs Authorization pipeline
// Middleware order matters:
app.UseAuthentication(); // WHO are you? → builds ClaimsPrincipal
app.UseAuthorization(); // WHAT can you do? → checks policies/roles
// Authentication schemes:
// - IdentityConstants.ApplicationScheme (cookie, default)
// - JwtBearerDefaults.AuthenticationScheme (Bearer token)
// Authorization:
// [Authorize] — must be logged in
// [Authorize(Roles = "Admin")] — must have role
// [Authorize(Policy = "CanEdit")] — custom policy
3.1
Packages & project scaffold
// dotnet new web -n MyAuthApi
// dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
// dotnet add package Microsoft.EntityFrameworkCore.Design
// dotnet tool install --global dotnet-ef
public class ApplicationUser : IdentityUser
{
public string? FullName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class AppDbContext : IdentityDbContext<ApplicationUser>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder); // required — creates Identity tables
builder.Entity<ApplicationUser>(e => e.Property(u => u.FullName).HasMaxLength(100));
}
}
3.2
Program.cs registration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Password rules
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
// Lockout — brute-force protection
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.AllowedForNewUsers = true;
// User rules
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = false; // set true in production
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders(); // password reset, email confirm, 2FA tokens
// Migrations:
// dotnet ef migrations add InitialIdentity
// dotnet ef database update
Identity never stores plain-text passwords. UserManager.CreateAsync hashes with PBKDF2 before touching the database.
4.1
Register endpoint (Minimal API)
public record RegisterDto(string Email, string Password, string FullName);
app.MapPost("/auth/register", async (
RegisterDto dto,
UserManager<ApplicationUser> userManager) =>
{
var user = new ApplicationUser
{
UserName = dto.Email,
Email = dto.Email,
FullName = dto.FullName
};
var result = await userManager.CreateAsync(user, dto.Password);
if (!result.Succeeded)
return Results.ValidationProblem(result.Errors
.GroupBy(e => e.Code)
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()));
return Results.Created($"/users/{user.Id}", new { user.Id, user.Email });
});
4.2
Login with cookie session
public record LoginDto(string Email, string Password, bool RememberMe);
app.MapPost("/auth/login", async (
LoginDto dto,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager) =>
{
var user = await userManager.FindByEmailAsync(dto.Email);
if (user is null) return Results.Unauthorized();
var result = await signInManager.PasswordSignInAsync(
user, dto.Password,
isPersistent: dto.RememberMe,
lockoutOnFailure: true);
return result switch
{
{ Succeeded: true } => Results.Ok(new { message = "Logged in" }),
{ IsLockedOut: true } => Results.Problem("Account locked", statusCode: 423),
{ RequiresTwoFactor: true } => Results.Ok(new { requires2fa = true }),
_ => Results.Unauthorized()
};
});
app.MapPost("/auth/logout", async (SignInManager<ApplicationUser> sm) =>
{
await sm.SignOutAsync();
return Results.Ok();
});
4.3
Cookie middleware for web apps
// For cookie-based apps, also configure:
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.LoginPath = "/login";
options.AccessDeniedPath = "/access-denied";
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
Watch Out
Never put JWT secrets in source code. Store in appsettings.json (dev only), User Secrets, Azure Key Vault, or environment variables in production.
// dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
var jwt = builder.Configuration.GetSection("Jwt");
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt["Issuer"],
ValidAudience = jwt["Audience"],
IssuerSigningKey = key,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization();
app.MapPost("/auth/token", async (
LoginDto dto,
UserManager<ApplicationUser> userManager,
IConfiguration config) =>
{
var user = await userManager.FindByEmailAsync(dto.Email);
if (user is null || !await userManager.CheckPasswordAsync(user, dto.Password))
return Results.Unauthorized();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email!),
new(ClaimTypes.Name, user.FullName ?? user.Email!)
};
var roles = await userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var jwt = config.GetSection("Jwt");
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: jwt["Issuer"],
audience: jwt["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return Results.Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(token),
expires = token.ValidTo
});
});
5.3
Protect endpoints & read claims
app.MapGet("/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
id = user.FindFirstValue(ClaimTypes.NameIdentifier),
email = user.FindFirstValue(ClaimTypes.Email),
roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value)
});
}).RequireAuthorization();
app.MapDelete("/admin/users/{id}", DeleteUser)
.RequireAuthorization("AdminOnly");
// Client sends: Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
5.4
Refresh tokens (production pattern)
// Short-lived access token (15–60 min) + long-lived refresh token (days)
// Store refresh tokens in DB (AspNetUserTokens or custom table)
// On /auth/refresh: validate refresh token, issue new access token, rotate refresh
public class RefreshToken
{
public string Token { get; set; } = Guid.NewGuid().ToString("N");
public string UserId { get; set; } = "";
public DateTime Expires { get; set; }
public bool IsRevoked { get; set; }
}
// Flow:
// 1. Login → return { accessToken, refreshToken, expires }
// 2. Access token expires → POST /auth/refresh { refreshToken }
// 3. Logout → revoke refresh token in DB
6.1
Roles — simple group-based access
// Seed roles at startup
using (var scope = app.Services.CreateScope())
{
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roles = ["Admin", "Manager", "User"];
foreach (var role in roles)
if (!await roleManager.RoleExistsAsync(role))
await roleManager.CreateAsync(new IdentityRole(role));
}
// Assign role to user
await userManager.AddToRoleAsync(user, "Admin");
await userManager.RemoveFromRoleAsync(user, "Manager");
var roles = await userManager.GetRolesAsync(user);
// Protect endpoint
app.MapGet("/admin/dashboard", GetDashboard).RequireAuthorization("AdminOnly");
6.2
Claims — fine-grained permissions
Prefer claims over roles when you need granular permissions like products:edit or invoices:approve.
// Add claim to user
await userManager.AddClaimAsync(user, new Claim("permission", "products:edit"));
// Add claim to role (all users in role inherit it)
await roleManager.AddClaimAsync(adminRole, new Claim("permission", "users:delete"));
// Read claims in endpoint
var canEdit = user.HasClaim("permission", "products:edit");
// Custom authorization policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditProducts", p =>
p.RequireClaim("permission", "products:edit"));
options.AddPolicy("AdminOnly", p =>
p.RequireRole("Admin"));
options.AddPolicy("Over18", p =>
p.RequireAssertion(ctx =>
{
var age = ctx.User.FindFirst("age")?.Value;
return age is not null && int.Parse(age) >= 18;
}));
});
6.3
Resource-based authorization
// "Can this user edit THIS specific order?"
public class OrderAuthorizationHandler
: AuthorizationHandler<EditOrderRequirement, Order>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
EditOrderRequirement requirement,
Order order)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (order.OwnerId == userId || context.User.IsInRole("Admin"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
builder.Services.AddSingleton<IAuthorizationHandler, OrderAuthorizationHandler>();
builder.Services.AddAuthorization(o =>
o.AddPolicy("EditOrder", p => p.Requirements.Add(new EditOrderRequirement())));
// In endpoint:
await authService.AuthorizeAsync(user, order, "EditOrder");
// 1. Create OAuth client at https://console.cloud.google.com
// 2. Redirect URI: https://localhost:5001/signin-google
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
});
// Razor/MVC: challenge redirects to Google
// return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Google");
// Minimal API — map the callback
app.MapGet("/signin-google", async (HttpContext ctx) =>
{
var result = await ctx.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
// Link or create user via UserManager
});
7.2
Link external login to existing account
// After OAuth callback — get external login info
var info = await signInManager.GetExternalLoginInfoAsync();
if (info is null) return Results.BadRequest("External auth failed");
// Try sign in with existing linked account
var signInResult = await signInManager.ExternalLoginSignInAsync(
info.LoginProvider, info.ProviderKey, isPersistent: false);
if (signInResult.Succeeded)
return Results.Ok(new { message = "Logged in via Google" });
// New user — create account and link
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
var user = new ApplicationUser { UserName = email, Email = email };
var createResult = await userManager.CreateAsync(user);
if (!createResult.Succeeded) return Results.BadRequest(createResult.Errors);
await userManager.AddLoginAsync(user, info);
await signInManager.SignInAsync(user, isPersistent: false);
return Results.Ok(new { message = "Account created via Google" });
2FA adds a second step after password — typically a 6-digit TOTP code from Google Authenticator or Microsoft Authenticator. Identity supports authenticator apps, SMS, and email codes out of the box.
8.1
Enable authenticator app
// Generate shared key + QR code URI for authenticator app
var user = await userManager.GetUserAsync(User);
var key = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await userManager.ResetAuthenticatorKeyAsync(user);
key = await userManager.GetAuthenticatorKeyAsync(user);
}
var email = await userManager.GetEmailAsync(user);
var uri = $"otpauth://totp/{email}?secret={key}&issuer=MyApp";
// Return { sharedKey: key, qrCodeUri: uri } to client
// User scans QR, enters 6-digit code to verify:
8.2
Verify & complete 2FA login
// Step 1: Password login returns RequiresTwoFactor = true
// Step 2: Client sends TOTP code
app.MapPost("/auth/2fa", async (
string code,
SignInManager<ApplicationUser> signInManager) =>
{
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
if (user is null) return Results.Unauthorized();
var result = await signInManager.TwoFactorAuthenticatorSignInAsync(
code, isPersistent: false, rememberClient: false);
return result.Succeeded
? Results.Ok(new { message = "2FA verified" })
: Results.Unauthorized();
});
// Recovery codes — generate once, store hashed, single-use
var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
app.MapPost("/auth/forgot-password", async (
string email,
UserManager<ApplicationUser> userManager,
IEmailSender emailSender) =>
{
var user = await userManager.FindByEmailAsync(email);
// Always return OK — don't reveal if email exists (security)
if (user is null) return Results.Ok();
var token = await userManager.GeneratePasswordResetTokenAsync(user);
var resetLink = $"https://myapp.com/reset?email={email}&token={Uri.EscapeDataString(token)}";
await emailSender.SendAsync(email, "Reset your password",
$"Click here to reset: {resetLink}");
return Results.Ok(new { message = "If the email exists, a reset link was sent." });
});
9.2
Reset password & confirm email
app.MapPost("/auth/reset-password", async (
string email, string token, string newPassword,
UserManager<ApplicationUser> userManager) =>
{
var user = await userManager.FindByEmailAsync(email);
if (user is null) return Results.BadRequest();
var result = await userManager.ResetPasswordAsync(user, token, newPassword);
return result.Succeeded ? Results.Ok() : Results.ValidationProblem(...);
});
// Email confirmation on registration
var confirmToken = await userManager.GenerateEmailConfirmationTokenAsync(user);
var confirmLink = $"https://myapp.com/confirm?userId={user.Id}&token={token}";
await emailSender.SendAsync(user.Email!, "Confirm your email", confirmLink);
// Later:
await userManager.ConfirmEmailAsync(user, token);
// Set options.SignIn.RequireConfirmedEmail = true to enforce
Before you ship
- Require confirmed email in production
- Enable account lockout (5 attempts, 15 min)
- HTTPS only —
CookieSecurePolicy.Always
- JWT secret in Key Vault, not appsettings
- Short JWT expiry (≤ 1 hour) + refresh tokens
- Rate-limit
/auth/login and /auth/register
- Never return "user not found" on forgot-password
- Log failed logins; alert on brute-force patterns
- Use
DataProtection for cookie encryption keys in multi-server deployments
10.2
Custom password validator
public class NoCommonPasswordValidator<TUser> : IPasswordValidator<TUser>
where TUser : class
{
private static readonly HashSet<string> Banned =
["password", "12345678", "qwerty123"];
public Task<IdentityResult> ValidateAsync(
UserManager<TUser> manager, TUser user, string? password)
{
if (password is not null && Banned.Contains(password.ToLower()))
return Task.FromResult(IdentityResult.Failed(
new IdentityError { Description = "Password is too common." }));
return Task.FromResult(IdentityResult.Success);
}
}
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(o => { ... })
.AddPasswordValidator<NoCommonPasswordValidator<ApplicationUser>>();
10.3
Identity + Blazor / MVC quick map
// Blazor Server / WASM with Identity:
// dotnet new blazor -int Server -au Individual (ships with Identity scaffold)
// MVC with Identity UI:
// dotnet new mvc -au Individual
// Both generate:
// - Areas/Identity/Pages (Razor UI for login/register/manage)
// - ApplicationUser + AppDbContext
// - Migrations ready to run
// For APIs only: skip the Razor UI, use Minimal API endpoints above