The Architecture of ASP.NET Core Identity: UserManager, SignInManager, Stores and Customization
ASP.NET Core Identity is not a single login system; UserManager runs user rules, SignInManager the HTTP session and store interfaces the data access, in separate layers. We build our own user entity with Guid keys and table names, and examine schema versions, the four registration methods and MapIdentityApi's non-JWT token and reusable refresh token with real outputs.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14 and Microsoft.EntityFrameworkCore.Sqlite 10.0.14.
Guess First
You added ASP.NET Core Identity to the Bookstore API (Application Programming Interface) with this line: builder.Services.AddIdentityCore<BookstoreUser>().AddEntityFrameworkStores<BookstoreIdentityDbContext>(). How many authentication schemes are registered in the application, and can you inject SignInManager?
[full] schemes: Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[full] SignInManager registered: True
[core] schemes: (no authentication services)
[core] SignInManager registered: False
[default] schemes: Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[default] SignInManager registered: True
[api] schemes: Identity.BearerAndApplication, Identity.Bearer, Identity.Application, Identity.External, Identity.TwoFactorRememberMe, Identity.TwoFactorUserId
[api] SignInManager registered: True
Zero. AddIdentityCore does not register the authentication services at all; there is not even an IAuthenticationSchemeProvider, and no SignInManager either. The four groups of lines above are the same application run with four different Identity registrations. People who think of Identity as a single "login system" are usually surprised here. Identity is really a layered library: user management is one layer, signing in is another, data access is a third. In this post we open up those layers, design our own user entity and measure where MapIdentityApi, which arrived with .NET 8, is enough and where it is not.
Layers: Managers, Store Interfaces, the EF Core Store
Managers sit at the top. UserManager<TUser> runs the business rules around users: creating them, validating and hashing passwords, normalizing e-mail addresses and user names, renewing the security stamp, generating tokens. RoleManager<TRole> manages roles. SignInManager<TUser> is the HTTP (Hypertext Transfer Protocol) side: password checks, lockout, the two-step flow and finally the SignInAsync call into cookie authentication. UserManager knows nothing about HTTP; it works in a console application too. SignInManager depends on HttpContext and on authentication schemes.
Managers do not touch data directly; they talk through store interfaces. The base interface IUserStore<TUser> only defines create, find, update and delete. The rest are optional interfaces: IUserPasswordStore, IUserEmailStore, IUserSecurityStampStore, IUserTwoFactorStore, IUserRoleStore and, new in .NET 10, IUserPasskeyStore. UserManager checks which of them the store it holds implements and tells you through its Supports* properties:
[api] store: UserStore`10
[api] SupportsUserPassword=True SupportsUserSecurityStamp=True SupportsUserTwoFactor=True SupportsUserPasskey=True SupportsUserRole=True
[default] store: UserOnlyStore`6
[default] SupportsUserPassword=True SupportsUserSecurityStamp=True SupportsUserTwoFactor=True SupportsUserPasskey=True SupportsUserRole=False
AddEntityFrameworkStores picks the ten-generic-parameter UserStore when a role type is registered and the six-parameter UserOnlyStore otherwise. Because AddDefaultIdentity adds no roles, SupportsUserRole=False; calling AddToRoleAsync then gets you a NotSupportedException. Writing your own store (for example when users already live in an old table read with Dapper) means implementing only the interfaces you need; the manager raises a clear error when it tries to use an unsupported feature.
Your Own User Entity: Guid Keys, Table Names and the Schema Version
The default IdentityUser has a string key and the tables get names like AspNetUsers and AspNetRoles. In Bookstore the key is a Guid, and the tables should follow the project's naming:
namespace Bookstore.Identity
{
public sealed class BookstoreUser : IdentityUser<Guid>
{
public string DisplayName { get; set; } = "";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class BookstoreRole : IdentityRole<Guid>;
public sealed class BookstoreIdentityDbContext(DbContextOptions<BookstoreIdentityDbContext> options)
: IdentityDbContext<BookstoreUser, BookstoreRole, Guid>(options)
{
protected override void OnModelCreating(ModelBuilder builder)
{
// Base first: Identity's key, index and relationship configuration is set up here
base.OnModelCreating(builder);
builder.Entity<BookstoreUser>(user =>
{
user.ToTable("Users");
user.Property(u => u.DisplayName).HasMaxLength(100);
});
builder.Entity<BookstoreRole>().ToTable("Roles");
builder.Entity<IdentityUserRole<Guid>>().ToTable("UserRoles");
builder.Entity<IdentityUserClaim<Guid>>().ToTable("UserClaims");
builder.Entity<IdentityUserLogin<Guid>>().ToTable("UserLogins");
builder.Entity<IdentityUserToken<Guid>>().ToTable("UserTokens");
builder.Entity<IdentityRoleClaim<Guid>>().ToTable("RoleClaims");
builder.Entity<IdentityUserPasskey<Guid>>().ToTable("UserPasskeys");
}
}
}
Calling base.OnModelCreating first is mandatory; call it afterwards and Identity writes its own table names back. Every property you add to the user entity (DisplayName, CreatedAt) becomes a column in the same table. Instead of stuffing profile data unrelated to identity (addresses, favourite genres) in here, keeping it in a separate Customer entity keeps the Identity table small and the sensitive data apart.
There is also an invisible setting: the schema version. Identity's Entity Framework Core (EF Core) model is versioned, and IdentityOptions.Stores.SchemaVersion decides which version gets built:
builder.Services.AddIdentityApiEndpoints<BookstoreUser>(options =>
{
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
options.Password.RequiredLength = 12;
options.User.RequireUniqueEmail = true;
})
.AddRoles<BookstoreRole>()
.AddEntityFrameworkStores<BookstoreIdentityDbContext>();
StoreOptions default SchemaVersion=0.0 Version1=1.0 Version2=2.0 Version3=3.0
If you don't set it, the value stays 0.0 and the comparison in the IdentityUserContext source treats it like Version1. Version 2 adds a default 128-character limit to key columns and a 256-character limit to PhoneNumber. Version 3 adds the passkey table on top. The database created with Version3:
$ sqlite3 bookstore-identity.db .tables
RoleClaims UserClaims UserPasskeys UserTokens
Roles UserLogins UserRoles Users
$ sqlite3 bookstore-identity.db ".schema UserPasskeys"
CREATE TABLE "UserPasskeys" (
"CredentialId" BLOB NOT NULL CONSTRAINT "PK_UserPasskeys" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"Data" TEXT NOT NULL,
CONSTRAINT "FK_UserPasskeys_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
The .NET 10 templates write Version3 explicitly. If you upgrade an old project to .NET 10 and want to add passkeys, you need to raise the version and generate a migration; calling the IUserPasskeyStore methods without changing the version fails because the table doesn't exist. Passkeys themselves are in posts 6 and 7. In the lab I used EnsureCreatedAsync to show the schema; in a real project use migrations (post 7 of the EF Core series: EF Core Migrations).
AddIdentity, AddIdentityCore, AddDefaultIdentity and AddIdentityApiEndpoints
The four registration methods produce the differences you saw in the opening output:
| Method | Authentication schemes | SignInManager | Roles | When |
|---|---|---|---|---|
AddIdentity<TUser, TRole> | 4 cookie schemes | Yes | Yes | Your own pages with MVC (Model-View-Controller) or Razor Pages |
AddIdentityCore<TUser> | None | No (add with AddSignInManager) | With AddRoles | User management only, your own authentication |
AddDefaultIdentity<TUser> | 4 cookie schemes | Yes | With AddRoles | The ready-made Identity UI (Razor Class Library) |
AddIdentityApiEndpoints<TUser> | Cookies + Identity.Bearer | Yes | With AddRoles | SPA (Single-Page Application) or mobile with MapIdentityApi |
Each of the four cookie schemes has a job. Identity.Application is the real session cookie. Identity.External temporarily holds the identity returned by an external provider such as Google until the account is linked. Identity.TwoFactorUserId holds the identity of a user who entered the right password but hasn't completed the second step yet. Identity.TwoFactorRememberMe is the "remember this device" marker. AddIdentity also sets DefaultAuthenticateScheme and DefaultChallengeScheme to Identity.Application; this line is usually the source of the "I added JWT (JSON Web Token) but it redirects to login" problem we discussed in post 1 of the series.
Use the Identity UI or write your own endpoints? The ready-made UI comes as Razor pages; in a server-rendered application it gives you a quick start, and you can copy the pages into the project with dotnet aspnet-codegenerator identity to customize them. Bookstore's front end is Angular, so the UI's pages are of no use to us; we will either use MapIdentityApi or write our own endpoints on top of UserManager and SignInManager. The account lifecycle in the next post takes the second route.
MapIdentityApi: What It Gives You and What It Doesn't
Post 34 of the ASP.NET Core Web API series introduced MapIdentityApi, which came with .NET 8. In Bookstore it is wired like this:
app.MapGroup("/account").MapIdentityApi<BookstoreUser>();
This single line adds the /register, /login, /refresh, /confirmEmail, /resendConfirmationEmail, /forgotPassword, /resetPassword, /manage/2fa and /manage/info endpoints. Registration returns the password rules as ProblemDetails; sign-in returns a token pair:
$ curl -s -i -X POST http://localhost:19130/account/register -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"short"}'
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PasswordTooShort":["Passwords must be at least 12 characters."],"PasswordRequiresNonAlphanumeric":["Passwords must have at least one non alphanumeric character."],"PasswordRequiresDigit":["Passwords must have at least one digit ('0'-'9')."],"PasswordRequiresUpper":["Passwords must have at least one uppercase ('A'-'Z')."]}}
$ curl -s -X POST http://localhost:19130/account/login -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"..."}' > login.json
$ python3 -c "import json;t=json.load(open('login.json'))['accessToken'];print('accessToken length', len(t), 'dots', t.count('.'))"
accessToken length 731 dots 0
The response itself (values shortened):
{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFCv7gu...","expiresIn":3600,"refreshToken":"CfDJ8EINq_wm4TFCv7gu..."}
There are no dots in the token, so it is not a JWT. You know the leading CfDJ8 from the previous post: this is an AuthenticationTicket encrypted with Data Protection. The first decoded bytes are Data Protection's magic header:
00000000: 09f0 c9f0 420d abfc 26e1 3142 bfb8 2ecf ....B...&.1B....
The practical consequence: only an application with the same Data Protection key ring can validate this token. Another API, an API gateway or a service written in another language cannot read it; there is no JWKS (JSON Web Key Set), no issuer, no audience. The default lifetimes come from BearerTokenOptions:
BearerTokenExpiration=01:00:00 RefreshTokenExpiration=14.00:00:00
There is a more important observation on the refresh side. I used the same refresh token twice:
$ curl -s -X POST http://localhost:19130/account/refresh -H "Content-Type: application/json" -d '{"refreshToken":"CfDJ8EINq_wm4TFC..."}'
{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFCv7guzy6SHCcQ-om9nuuqUZ5o3t3NbUAivYyasdXVjE1Mmj-Q_nZgE6X9j0RQsxDDvXJ
second refresh with same token: 200
The second one returned 200 too. The refresh token is not stored on the server; it is itself an encrypted ticket, so there is nowhere to record "this token has been used once". There is rotation (every refresh returns a new refresh token), but the old one also stays valid for 14 days. We build reuse detection, the way to notice a stolen refresh token being used, ourselves in post 9. Because the endpoint checks the security stamp, a password change invalidates old refresh tokens, but it does not touch access tokens:
change password: 200
refresh after password change: 401
old access token after password change: 200
The bearer handler does not check the security stamp on every request; the access token in the hands of a user whose password changed keeps working until its one-hour lifetime ends. That is exactly why access tokens are kept short.
When is MapIdentityApi enough? When you have a single API and your own SPA or mobile app talking to it, no other service needs to validate this token and you don't let third-party applications sign in. For an SPA on the same origin, you can also get a cookie instead of a token with ?useCookies=true:
$ curl -s -i -X POST "http://localhost:19130/account/login?useCookies=true" -H "Content-Type: application/json" -d '{"email":"ada@bookstore.test","password":"..."}'
HTTP/1.1 200 OK
Set-Cookie: .AspNetCore.Identity.Application=CfDJ8EINq_wm4TFCv7guzy6SHCfPdbgjK37XQJ3zD0NtqGVnUc9MFjWZFOjEgbsOz...
When is it not enough? When several APIs must accept the same identity, when you need single sign-on (SSO), when a partner's application will access Bookstore on the user's behalf, or when you have a client that expects a standard protocol (OAuth 2.0, OIDC (OpenID Connect)). At that point you need an authorization server; from post 10 of the series onwards we move to that side.
In Production
Use Identity's defaults knowingly. SecurityStampValidator checks every 30 minutes (we printed it in the previous post); a password change can take that long to reach other devices. Lockout is on by default, but brute force and lockout settings were covered in post 12 of the Web Security series and how passwords are hashed in post 8. You will see that the hash created during registration above starts with AQAAAAIAAYagAAAAE...; that prefix carries the format version, the PRF (Pseudo-Random Function) and the iteration count.
.NET 10 brought Identity metrics under the Microsoft.AspNetCore.Identity meter: aspnetcore.identity.user.create.duration, aspnetcore.identity.sign_in.check_password_attempts, aspnetcore.identity.sign_in.sign_ins and others. A sudden rise in failed password attempts is one of the earliest signals of credential stuffing; wiring that metric to an alert is a few lines of work.
On the scale side: FindByEmailAsync works through the NormalizedEmail column, and Identity puts an index on it (EmailIndex). But even with RequireUniqueEmail = true that index is not unique; UserValidator checks uniqueness in the application. Two registration requests arriving at the same moment can pass that check together. If you want a real guarantee, add a unique index on NormalizedEmail in the database.
Common Pitfalls
First, taking Identity for an authorization framework. Identity manages users, passwords, roles and tokens; it does not answer "may Ada refund this order?". Roles live in Identity, but the decision is made in ASP.NET Core's authorization layer, in policies and handlers. The mechanics are in the first post of the upcoming ASP.NET Core Security series, and authorization models in post 16 of this series.
Second, returning the user entity from the API as is. I deliberately put a bad endpoint into the lab:
// Bad example: never return the entity directly
app.MapGet("/debug/user-entity", async (HttpContext context, UserManager<BookstoreUser> users) =>
TypedResults.Ok(await users.GetUserAsync(context.User)))
.RequireAuthorization();
{
"displayName": "",
"createdAt": "2026-11-08T05:35:34.166991+00:00",
"id": "f57fcedf-35a2-477e-bb64-d144dd711f4a",
"userName": "ada@bookstore.test",
"normalizedUserName": "ADA@BOOKSTORE.TEST",
"email": "ada@bookstore.test",
"normalizedEmail": "ADA@BOOKSTORE.TEST",
"emailConfirmed": false,
"passwordHash": "AQAAAAIAAYagAAAAEJkY2iZIsMS4rOvaKLfy5Ytlo3ApWOhD...",
"securityStamp": "IHZ6I25DJPP7FF3GXV5OQCT6RO6U4LNP",
"concurrencyStamp": "c4a27ff7-7375-4eb7-aeba-f8002cce43c9",
"phoneNumber": null,
"phoneNumberConfirmed": false,
"twoFactorEnabled": false,
"lockoutEnd": null,
"lockoutEnabled": true,
"accessFailedCount": 0
}
The password hash and the security stamp are in the response. The hash becomes open to offline cracking; the security stamp is the secret behind the revocation mechanism. The fix is always a DTO (Data Transfer Object):
app.MapGet("/me", async (HttpContext context, UserManager<BookstoreUser> users) =>
{
BookstoreUser? user = await users.GetUserAsync(context.User);
return user is null
? Results.NotFound()
: TypedResults.Ok(new MeResponse(user.Id, user.Email!, user.DisplayName, await users.GetRolesAsync(user)));
}).RequireAuthorization();
public sealed record MeResponse(Guid Id, string Email, string DisplayName, IList<string> Roles);
{"id":"f57fcedf-35a2-477e-bb64-d144dd711f4a","email":"ada@bookstore.test","displayName":"","roles":[]}
Third, putting base.OnModelCreating at the end and wondering why the table names didn't change. Fourth, writing passkey code without setting SchemaVersion. Fifth, using AddIdentityCore and trying to inject SignInManager; as the opening output showed, it is not registered, and you have to add AddSignInManager() and the authentication configuration yourself.
A Small Experiment
★☆☆ In the lab, delete the SchemaVersion line and recreate the database. Compare with .schema how UserPasskeys disappears from the .tables output and how the length limit of the LoginProvider column in the UserLogins table changes.
★★☆ Start with AddIdentityCore and build a working password sign-in by adding only AddSignInManager() and AddAuthentication().AddIdentityCookies(). Print which schemes get registered and compare with the output of AddIdentity.
★★★ Add a unique index on NormalizedEmail. Then write a small script that sends 20 registration requests with the same e-mail in parallel; compare how many rows end up in the database with and without the index.
We have seen Identity's skeleton. In the next post we build an account's whole life on top of it: registration, e-mail confirmation, password reset, external login and account deletion, including the lifetime of token providers and dropping the other sessions after a reset.