ASP.NET Core Identity for APIs

Add register, login, refresh and password reset endpoints with AddIdentityApiEndpoints and MapIdentityApi, and know their limits and passkey support.

Syntaxbuilder.Services.AddIdentityApiEndpoints<ShopUser>() .AddEntityFrameworkStores<ShopIdentityDbContext>(); app.MapGroup("/account").MapIdentityApi<ShopUser>();

ASP.NET Core Identity is a user store plus the logic around it: password hashing, lockout, email confirmation, two-factor codes and, in .NET 10, passkeys. AddIdentityApiEndpoints<TUser>() registers Identity, SignInManager, the default token providers and an authentication scheme that accepts either Identity's bearer token or its application cookie. MapIdentityApi<TUser>() maps JSON endpoints: /register, /login, /refresh, /confirmEmail, /resendConfirmationEmail, /forgotPassword, /resetPassword, /manage/2fa and /manage/info. Storage comes from AddEntityFrameworkStores<TContext>() (package Microsoft.AspNetCore.Identity.EntityFrameworkCore).

Tokens are not JWTs

POST /login returns tokenType, accessToken, expiresIn (3600) and refreshToken. Both tokens are opaque, protected with Data Protection; access tokens last 1 hour and refresh tokens 14 days. Only this application can read them, so another service cannot validate them, and there is no per-token revocation list: /refresh rejects the refresh token when the user's security stamp has changed, which is how a password change ends old sessions. Already-issued access tokens are not re-checked and stay valid until they expire (up to 1 hour); shorten BearerTokenExpiration if that window is too long. /login?useCookies=true writes the Identity cookie instead, which suits a browser front end on the same site.

Passwords and lockout

PasswordHasher<TUser> stores PBKDF2 with HMAC-SHA512, a 128-bit salt and 100,000 iterations. If you raise PasswordHasherOptions.IterationCount, verification returns SuccessRehashNeeded and UserManager rehashes on the next successful sign-in. Lockout defaults: 5 failed attempts, 5 minutes, enabled for new users, and the login endpoint counts failures. The default password rules require 6 characters with a digit, a lower-case letter, an upper-case letter and a symbol; current guidance prefers length over composition, so teams set a longer minimum and relax the character classes.

Passkeys in .NET 10

Identity in .NET 10 can register and verify WebAuthn passkeys: SignInManager.MakePasskeyCreationOptionsAsync and PerformPasskeyAttestationAsync to register, MakePasskeyRequestOptionsAsync and PasskeySignInAsync to sign in, with IdentityPasskeyOptions for the server domain and timeouts. Passkeys need schema version 3 (options.Stores.SchemaVersion = IdentitySchemaVersions.Version3, which adds a passkeys table) and a migration. MapIdentityApi does not map passkey endpoints; you add them, or use the Blazor template with individual accounts.

When to use it, and what bites

  • Fits one first-party app with its own users. For several APIs, social login, SSO or enterprise customers, use an identity provider and JWT bearer.
  • The default IEmailSender sends nothing. Confirmation and reset emails silently vanish until you register a real IEmailSender<TUser>.
  • /register reports duplicate emails, which allows account enumeration; rate-limit it and the login endpoint.
  • Tokens and cookies depend on Data Protection: share the key ring across replicas or every login dies on deploy.

Example

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ShopIdentityDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Identity")));

builder.Services.AddAuthorization();
builder.Services
    .AddIdentityApiEndpoints<ShopUser>(options =>
    {
        options.User.RequireUniqueEmail = true;
        options.SignIn.RequireConfirmedEmail = true;

        options.Password.RequiredLength = 12;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireUppercase = false;

        options.Lockout.MaxFailedAccessAttempts = 5;
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);

        options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; // passkeys table
    })
    .AddEntityFrameworkStores<ShopIdentityDbContext>();

// Without this the default sender is a no-op and confirmation links never arrive.
builder.Services.AddHttpClient<IEmailSender<ShopUser>, MailApiEmailSender>(client =>
    client.BaseAddress = new Uri(builder.Configuration["Mail:BaseUrl"] ?? "https://mail.internal.example.com/"));

var app = builder.Build();

app.MapGroup("/account").MapIdentityApi<ShopUser>();

app.MapGet("/orders/mine", (ClaimsPrincipal user) =>
        TypedResults.Ok(new { userId = user.FindFirstValue(ClaimTypes.NameIdentifier), orders = Array.Empty<int>() }))
    .RequireAuthorization();

app.Run();

public sealed class ShopUser : IdentityUser
{
    public string? DisplayName { get; set; }
}

public sealed class ShopIdentityDbContext(DbContextOptions<ShopIdentityDbContext> options)
    : IdentityDbContext<ShopUser>(options);

public sealed class MailApiEmailSender(HttpClient http) : IEmailSender<ShopUser>
{
    public Task SendConfirmationLinkAsync(ShopUser user, string email, string confirmationLink) =>
        Send(email, "Confirm your account", $"Confirm your email: {confirmationLink}");

    public Task SendPasswordResetLinkAsync(ShopUser user, string email, string resetLink) =>
        Send(email, "Reset your password", $"Reset your password: {resetLink}");

    public Task SendPasswordResetCodeAsync(ShopUser user, string email, string resetCode) =>
        Send(email, "Your reset code", $"Your password reset code is {resetCode}");

    private async Task Send(string to, string subject, string text)
    {
        using var response = await http.PostAsJsonAsync("v1/messages", new { to, subject, text });
        response.EnsureSuccessStatusCode();
    }
}

When to use it

  • Give a single first-party shop app email and password accounts with confirmation, reset and lockout without writing auth code.
  • Add passkey sign-in next to passwords so returning customers log in with Face ID or Windows Hello.
  • Cut off a customer's stolen phone by changing their security stamp: it cannot refresh, and its access token expires within the hour.

More examples

Calling the endpoints

Clients refresh before <code>expiresIn</code> elapses and replace both tokens with the new pair. A failed login returns 401 with a ProblemDetails body whose <code>detail</code> is <code>Failed</code> or <code>Lockedout</code> (the <code>SignInResult</code> text), and a login with <code>RequireConfirmedEmail</code> before confirmation reads <code>NotAllowed</code>. The <code>/login</code> body also accepts <code>twoFactorCode</code> and <code>twoFactorRecoveryCode</code> once 2FA is enabled through <code>/manage/2fa</code>.

Example · http
@shop = https://localhost:7212

### Register (200 with an empty body on success, 400 ValidationProblem with DuplicateEmail or password errors)
POST {{shop}}/account/register
Content-Type: application/json

{ "email": "[email protected]", "password": "long-passphrase-for-ana-2026" }

### Log in with bearer tokens
POST {{shop}}/account/login
Content-Type: application/json

{ "email": "[email protected]", "password": "long-passphrase-for-ana-2026" }

### Response:
### { "tokenType": "Bearer", "accessToken": "CfDJ8...", "expiresIn": 3600, "refreshToken": "CfDJ8..." }

### Log in with a cookie instead (browser front end on the same site)
POST {{shop}}/account/login?useCookies=true
Content-Type: application/json

{ "email": "[email protected]", "password": "long-passphrase-for-ana-2026" }

### Use the access token
GET {{shop}}/orders/mine
Authorization: Bearer <access-token>

### Get a new pair before the hour is up
POST {{shop}}/account/refresh
Content-Type: application/json

{ "refreshToken": "<refresh-token>" }

Passkey registration and sign-in endpoints (.NET 10)

WebAuthn runs in the browser, so these endpoints suit a cookie-based front end: <code>PasskeySignInAsync</code> signs in with <code>SignInManager.AuthenticationScheme</code>, the Identity application cookie by default. Passkeys are phishing-resistant because the browser binds each credential to your domain; set <code>IdentityPasskeyOptions.ServerDomain</code> when the API host differs from the site the user sees, or attestation fails with an origin mismatch. Keep password sign-in or recovery codes as a fallback for users without a passkey.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;

public static class PasskeyEndpoints
{
    public static RouteGroupBuilder MapPasskeys(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/account/passkeys");

        // 1. Signed-in user asks for options, passes them to navigator.credentials.create().
        group.MapPost("/creation-options", async (ClaimsPrincipal principal, UserManager<ShopUser> users, SignInManager<ShopUser> signIn) =>
        {
            var user = await users.GetUserAsync(principal);
            if (user is null) return Results.Unauthorized();
            var optionsJson = await signIn.MakePasskeyCreationOptionsAsync(new PasskeyUserEntity
            {
                Id = await users.GetUserIdAsync(user),
                Name = await users.GetUserNameAsync(user) ?? user.Email ?? "user",
                DisplayName = user.DisplayName ?? user.Email ?? "user",
            });
            return Results.Content(optionsJson, "application/json");
        }).RequireAuthorization();

        // 2. Browser posts the credential back; Identity verifies it against the stored challenge.
        group.MapPost("/", async (PasskeyCredential body, ClaimsPrincipal principal, UserManager<ShopUser> users, SignInManager<ShopUser> signIn) =>
        {
            var user = await users.GetUserAsync(principal);
            if (user is null) return Results.Unauthorized();
            var attestation = await signIn.PerformPasskeyAttestationAsync(body.CredentialJson);
            if (!attestation.Succeeded)
                return Results.Problem(attestation.Failure?.Message, statusCode: StatusCodes.Status400BadRequest);
            var saved = await users.AddOrUpdatePasskeyAsync(user, attestation.Passkey);
            return saved.Succeeded ? Results.NoContent() : Results.Problem("Could not save the passkey.");
        }).RequireAuthorization();

        // 3. Anonymous sign-in: options for navigator.credentials.get(), then verify and issue the cookie.
        group.MapPost("/request-options", async (SignInManager<ShopUser> signIn) =>
            Results.Content(await signIn.MakePasskeyRequestOptionsAsync(user: null), "application/json"));

        group.MapPost("/sign-in", async (PasskeyCredential body, SignInManager<ShopUser> signIn) =>
        {
            var result = await signIn.PasskeySignInAsync(body.CredentialJson);
            return result.Succeeded ? Results.NoContent() : Results.Unauthorized();
        });

        return group;
    }
}

public sealed record PasskeyCredential(string CredentialJson);

public sealed class ShopUser : IdentityUser
{
    public string? DisplayName { get; set; }
}

Raising the password hash cost safely

The iteration count is stored inside each hash, so raising it never locks anyone out: old hashes verify with their own count and report <code>SuccessRehashNeeded</code>, and <code>UserManager</code> writes the stronger hash during the next successful sign-in. Measure the cost first: each login spends this CPU time on the request thread, and a burst of login attempts at a high iteration count is a cheap way to exhaust a small pod, which is one more reason to rate-limit <code>/login</code>.

Example · csharp
using Microsoft.AspNetCore.Identity;

var builder = WebApplication.CreateBuilder(args);

// New hashes use 210,000 PBKDF2 iterations; old hashes still verify and are upgraded on next login.
builder.Services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
builder.Services.AddIdentityCore<IdentityUser>(); // registers IPasswordHasher<IdentityUser>

var app = builder.Build();

var oldHasher = new PasswordHasher<IdentityUser>();
var newHasher = app.Services.GetRequiredService<IPasswordHasher<IdentityUser>>();
var user = new IdentityUser("[email protected]");

string storedHash = oldHasher.HashPassword(user, "long-passphrase-for-ana-2026");
var result = newHasher.VerifyHashedPassword(user, storedHash, "long-passphrase-for-ana-2026");

Console.WriteLine(result); // SuccessRehashNeeded: UserManager stores a new hash after this sign-in

Discussion

  • Be the first to comment on this lesson.