RO EN

ASP.NET Core Identity – Deep Dive: everything you need to know

ASP.NET Core Identity – Deep Dive: everything you need to know
Doru Bulubașa
31 March 2026

ASP.NET Core Identity is the authentication and authorization system natively included in the .NET ecosystem. If you have ever worked with a web application in ASP.NET Core, you have probably encountered it — but few developers explore its full depth. This article does exactly that.

Basic Architecture

Identity is based on two central services: UserManager<TUser> and SignInManager<TUser>. These are injected through DI and orchestrate almost every user-related operation.

UserManager handles CRUD operations on users: creation, deletion, password change, role management, claims, email confirmation tokens. SignInManager manages the session: password login, external login, 2FA, logout.

Registration in Program.cs looks like this:

builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    options.Password.RequiredLength = 8;
    options.User.RequireUniqueEmail = true;
    options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();

Custom User — extending IdentityUser

The default IdentityUser model covers the basic fields: email, username, password hash, phone number. But in real applications you need more — full name, avatar, registration date, subscription plan.

The solution is to create a class that inherits from IdentityUser:

public class ApplicationUser : IdentityUser
{
    public string FirstName { get; set; } = default!;
    public string LastName  { get; set; } = default!;
    public string? AvatarUrl { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public string PlanId { get; set; } = "basic";
}

Once defined, you replace IdentityUser with ApplicationUser everywhere — in AddIdentity<ApplicationUser, ...>(), in UserManager<ApplicationUser>, and in the EF context.

Custom Password Validators

The default password validators are functional but rigid. If you want custom rules — for example, to forbid passwords containing the username, or to require at least one special character from a specific list — you implement IPasswordValidator<TUser>.

public class NoUsernameInPasswordValidator : IPasswordValidator<ApplicationUser>
{
    public Task<IdentityResult> ValidateAsync(
        UserManager<ApplicationUser> manager,
        ApplicationUser user,
        string? password)
    {
        if (password != null &&
            user.UserName != null &&
            password.Contains(user.UserName, StringComparison.OrdinalIgnoreCase))
        {
            return Task.FromResult(IdentityResult.Failed(
                new IdentityError
                {
                    Code = "PasswordContainsUsername",
                    Description = "The password cannot contain the username."
                }));
        }

        return Task.FromResult(IdentityResult.Success);
    }
}

Registration in DI:

builder.Services.AddScoped<IPasswordValidator<ApplicationUser>,
    NoUsernameInPasswordValidator>();

You can register as many validators as you want — Identity runs them all and aggregates the errors.

Two-Factor Authentication (2FA)

Identity natively supports 2FA via TOTP (Time-based One-Time Password) — compatible with Google Authenticator, Microsoft Authenticator, and any standard TOTP app.

The complete flow involves three steps: user enabling 2FA, generating a QR code with the secret, and verifying the code at each login.

Generating the key and URI for the QR code:

var userId = await _userManager.GetUserIdAsync(user);
var key    = await _userManager.GetAuthenticatorKeyAsync(user);

if (string.IsNullOrEmpty(key))
{
    await _userManager.ResetAuthenticatorKeyAsync(user);
    key = await _userManager.GetAuthenticatorKeyAsync(user);
}

var uri = _urlEncoder.Encode(
    $"otpauth://totp/MyApp:{user.Email}?secret={key}&issuer=MyApp");

Verifying the code entered by the user:

var isValid = await _userManager.VerifyTwoFactorTokenAsync(
    user,
    _userManager.Options.Tokens.AuthenticatorTokenProvider,
    code.Replace(" ", "").Replace("-", ""));

if (isValid)
    await _userManager.SetTwoFactorEnabledAsync(user, true);

At login, after password validation, SignInManager automatically detects that the user has 2FA enabled and returns SignInResult.TwoFactorRequired, redirecting the flow to the code verification step.

Account Lockout

Lockout protects against brute-force attacks. Identity manages it automatically if you configure it correctly:

builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    options.Lockout.DefaultLockoutTimeSpan  = TimeSpan.FromMinutes(15);
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.AllowedForNewUsers      = true;
})

When you call SignInManager.PasswordSignInAsync(..., lockoutOnFailure: true), Identity automatically increments the failed attempt counter. Upon exceeding the threshold, the account is locked for the configured interval.

You can check the lockout status and manage it manually:

// Check
bool isLockedOut = await _userManager.IsLockedOutAsync(user);

// Manual reset (e.g., from admin panel)
await _userManager.ResetAccessFailedCountAsync(user);
await _userManager.SetLockoutEndDateAsync(user, null);

An important detail: GetLockoutEndDateAsync returns a DateTimeOffset? — if it is in the future, the account is locked; if it is null or in the past, the account is active.

External Providers (OAuth 2.0)

Identity integrates natively with external providers through OAuth/OIDC authentication middleware. The most common are Google, Microsoft, GitHub, and Facebook.

Configuration for Google and Microsoft in Program.cs:

builder.Services.AddAuthentication()
    .AddGoogle(options =>
    {
        options.ClientId     = configuration["Auth:Google:ClientId"]!;
        options.ClientSecret = configuration["Auth:Google:ClientSecret"]!;
    })
    .AddMicrosoftAccount(options =>
    {
        options.ClientId     = configuration["Auth:Microsoft:ClientId"]!;
        options.ClientSecret = configuration["Auth:Microsoft:ClientSecret"]!;
    });

The external login flow has two stages. First: redirect to the external provider.

var redirectUrl  = Url.Action("ExternalLoginCallback", "Account");
var properties   = _signInManager.ConfigureExternalAuthenticationProperties(
    provider, redirectUrl);
return Challenge(properties, provider);

Second: processing the callback after authentication at the provider.

var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null) return RedirectToAction("Login");

var result = await _signInManager.ExternalLoginSignInAsync(
    info.LoginProvider, info.ProviderKey, isPersistent: false);

if (!result.Succeeded)
{
    // New user — create local account
    var email = info.Principal.FindFirstValue(ClaimTypes.Email)!;
    var user  = new ApplicationUser { UserName = email, Email = email };

    await _userManager.CreateAsync(user);
    await _userManager.AddLoginAsync(user, info);
    await _signInManager.SignInAsync(user, isPersistent: false);
}

Identity automatically stores the link between the local and external account in the AspNetUserLogins table, allowing a user to have multiple providers associated with the same account.

Conclusion

ASP.NET Core Identity is a mature and extensible system — it covers 90% of the authentication needs of a standard web application. Understanding the internal mechanisms (how lockout works, how external claims are processed, how validators chain) makes the difference between a fragile and a solid implementation.

If your project grows and you need enterprise-level federated authentication — multi-tenant, SSO, advanced RBAC — the natural next step is Azure Entra ID or Entra CIAM, which we will explore in a future article.