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.

01

What is ASP.NET Core Identity?

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
02

Architecture — the moving parts

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
03

EF Core Setup — from zero to migrated

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
04

Register & Login — cookie authentication

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();
05

JWT Bearer — API token authentication

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.

5.1

Configure JWT Bearer

// 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();
5.2

Issue JWT on login

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
06

Roles, Claims & Authorization Policies

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");
07

External Logins — Google, Microsoft, GitHub

7.1

Google OAuth setup

// 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" });
08

Two-Factor Authentication (2FA)

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);
09

Password Reset & Email Confirmation

9.1

Forgot password flow

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
10

Production Checklist & Security

10.1

Security checklist

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
Mastery Move — where to go next

1. REST API Best Practices — secure your Identity-protected endpoints professionally.

2. T-SQL Deep-Dive — optimize AspNetUsers queries and indexes at scale.

3. Complete C# Guide — GraphQL auth, EF Core patterns, concurrent collections.