Upgrade to 4.0
This page lists every breaking change from 3.4.1 to 4.0.0 and its migration. A taken email on POST /register is not one of them.
New hosts use Password auth. Existing hosts keep the 3.x facade. Which call to make is in Pick your setup. Add and Map pairs are in the composition table. This page does not copy that table.
Options that stay
AddAuthEndpoints keeps the 3.4.1 options and the AuthEndpointsSignIn and EmailCode.Enabled names. Nothing to migrate there. POST /register already returned an empty 200 for a taken email in 3.4.1. There is no migration for DuplicateUserName or DuplicateEmail.
MapAuthEndpoints exceptions
AddAuthEndpoints and MapAuthEndpoints do not gain 4.0-only behaviour, except for three cases:
- Opt-in body transport. A
RefreshTokenTransportother thanCookieinJwt.Configureis copied ontoJwtSessionOptions. The defaultCookiedoes not overwrite an earlierConfigure<JwtSessionOptions>. - Unknown-email password-hash verify on facade
POST /create. That handler callsDefaultAuthenticator. Facade login does not run the unknown-email hash verify, so an unknown email can return faster than a wrong password. - An absolute refresh lifetime, and a lost concurrent refresh that revokes the family. Hashed tokens, rotation, reuse that revokes the family, the security-stamp bind, and logout revoke are unchanged from 3.4.1.
The shared JWT POST /create responses and the password /login pipeline do not apply to MapAuthEndpoints, apart from that unknown-email hash verify.
Email-code table gains Purpose
Migration. Add an EF migration and apply it before you deploy. Existing rows default to sign-in.
Hosts that call UseEmailCodes() add an EF migration and apply it before deploying. The library does not ship a Migrations folder.
The model adds Purpose (int, not null, default 0) on AuthEndpoints.AuthEndpointsEmailCodes. It drops the index on (EmailHash, CreatedAt) and adds (EmailHash, Purpose, CreatedAt), named IX_AuthEndpointsEmailCodes_EmailHash_Purpose_CreatedAt. Existing rows default to sign-in (0).
dotnet ef migrations add AuthEndpoints4 --project YourDataProject --startup-project YourHost
Email-code messages include ReAuth
Migration. Handle EmailCodePurpose.ReAuth in IEmailCodeSender<TUser>. The message confirms identity. It is not a sign-in.
EmailCodePurpose gains ReAuth (2). IEmailCodeSender<TUser> implementations that switch on purpose handle ReAuth: the message is "confirm it's you", not a sign-in. LinkUrl is null. Sign-up stays SignUp (1). Those rows are still stored as sign-in.
Confirm identity accepts an email code
Migration. Match this 400 on status. Do not match the sentence.
ConfirmIdentityRequest gains EmailCode. POST /confirmIdentity accepts exactly one of password, twoFactorCode, twoFactorRecoveryCode, credentialJson, or emailCode. The 400 body is:
Provide exactly one of Password, TwoFactorCode, TwoFactorRecoveryCode, CredentialJson, or EmailCode.
Match that response on status. A password, authenticator, or passkey user who posts only emailCode gets 400 with title EmailCodeProofUnavailable when step-up by email is not offered.
authMethods adds emailCode
Migration. Add the EmailCode argument where you construct AuthMethodsResponse.
AuthMethodsResponse gains bool EmailCode as the last constructor argument. JSON responses include emailCode. On MapAccountManagementEndpoints and MapReAuthEndpoints it is true when email-code step-up is registered and available for that user. The AddAuthEndpoints / MapAuthEndpoints facade keeps emailCode false and does not map POST /confirmIdentity/emailCode/send. ReAuthEndpoints<TUser>.AuthMethods(ClaimsPrincipal, UserManager<TUser>) still forwards with EmailCode false.
Email-code cooldown and send cap are per purpose
Migration. No host change beyond the Purpose column. A step-up send does not consume the sign-in cooldown or send cap.
ResendCooldown and MaxSendsPerEmail apply per stored purpose. A step-up send does not supersede a sign-in code, and it does not consume that address's sign-in cooldown or send cap. The reverse is also true. No migration beyond the Purpose column.
Per-method sign-in completers are obsolete
Migration. Implement ISignInCompleter<TUser> and register it with AddSignInCompleter. Pass that name to the map, or set SignInCompletionOptions.Default.
Removed in 5.0. An explicitly registered 3.x completer still runs when the sign-in map does not pass session or completer.
| 3.x | 4.0 |
|---|---|
IEmailCodeSignInCompleter<TUser> / EmailCodeSignInContext | ISignInCompleter<TUser> / SignInCompletionContext |
AddEmailCodeSignInCompleter<TUser, TCompleter>() | AddSignInCompleter<TUser, TCompleter>(name) |
IPasskeySignInCompleter<TUser> / PasskeySignInCompletionContext | same keyed completer |
AddPasskeySignInCompleter<TUser, TCompleter>() | AddSignInCompleter<TUser, TCompleter>(name) |
IExternalLoginCompleter<TUser> | ISignInCompleter<TUser> with SignInMethod.External. AddCompleter<T>() still wins when the map passes no session and no completer |
ExternalAuthBuilder.AddCompleter<TCompleter>() | AddSignInCompleter<TUser, TCompleter>(name), or MapExternalAuthEndpoints<TUser>(session) |
IdentityEmailCodeSignInCompleter, JwtEmailCodeSignInCompleter, IdentityPasskeySignInCompleter, JwtPasskeySignInCompleter, CookieExternalLoginCompleter, JwtExternalLoginCompleter | Sign-in completer |
Sessions, the passkey string overload, password login, and a custom completer are on Sign-in completer. AddCompleter<T>() still wins when the map passes no session and no completer. AuthEndpointsSession.Jwt with RefreshTokenTransport.Body throws at map time.
JWT POST /create runs the shared password pipeline
Migration. Match POST /create failures on status and title. This does not apply to MapAuthEndpoints, except the unknown-email hash verify below.
MapJwtPasswordEndpoints and MapJwtAuthEndpoints use the shared password pipeline. Status, title, and lockout are in JWT. MapAuthEndpoints keeps the 3.4.1 create handler. The shared password pipeline does not apply to MapAuthEndpoints.
The unknown-email hash verify is the one change MapAuthEndpoints sees. It applies to facade POST /create because that handler calls DefaultAuthenticator. Facade login does not run the unknown-email hash verify, so an unknown email can return faster than a wrong password.
Password /login no longer calls SignInManager.PasswordSignInAsync / TwoFactorAuthenticatorSignInAsync / TwoFactorRecoveryCodeSignInAsync
Migration. Move sign-in side effects out of PasswordSignInAsync, TwoFactorAuthenticatorSignInAsync, and TwoFactorRecoveryCodeSignInAsync. This does not apply to MapAuthEndpoints.
MapPasswordEndpoints<TUser>() and MapPasswordSignInEndpoints<TUser>() use CheckPasswordSignInAsync and the completer. Responses and a custom SignInManager<TUser> are in Sign-in completer. An explicit PasswordLoginMode uses the 3.4.1 handlers. MapCookieAuthEndpoints, MapBearerAuthEndpoints, and MapAuthEndpoints do too. This login change does not apply to MapAuthEndpoints. Facade login does not run the unknown-email hash verify, so an unknown email can return faster than a wrong password.
MapPasswordEndpoints overloads
Migration. Recompile assemblies built against AuthEndpoints 3.4.x. Source that calls MapPasswordEndpoints still compiles.
3.4.x had one method, MapPasswordEndpoints<TUser>(PasswordLoginMode login = PasswordLoginMode.Cookie, string? confirmEmailEndpointName = null). That optional-parameter method is now separate overloads: no arguments, PasswordLoginMode, AuthEndpointsSession, and a completer name. A binary built against 3.4.x passes PasswordLoginMode.Cookie into the call, so it binds to the mode overload.
Per-session and shared routes
No breaking change for route paths. AddAuthEndpoints / MapAuthEndpoints stay on the 3.4.1 route set, including antiforgery and rate limits. MapIdentityManagementApi, MapCookieAuthEndpoints, MapBearerAuthEndpoints, MapJwtAuthEndpoints, MapPasskeyEndpoints, MapPasswordEndpoints, MapEmailCodeEndpoints, and MapPasskeySignInEndpoints keep their names and routes.
MapPasskeySignInEndpoints still maps requestOptions, register/options, register, and login, and still ensures confirm-email. Hosts that want the split call MapPasskeySignUpEndpoints and MapPasskeyRegisterOptionsEndpoints. AddPasskeyEndpoints remains the full set and calls the finer Add* methods.
Routes that finish in a completer are mapped once per session. The others are mapped once. Passkey POST /passkeys/requestOptions finishes the ceremony on that session's login route, so it is mapped per session with login, not on the shared map. Add and Map pairs, routes, and scope are in the composition table.
MapSignInEndpoints<TUser>(AuthEndpointsSession) maps the per-session routes for modules that are registered, plus that session's routes. It logs a warning when no sign-in module is registered. Cookie and Identity bearer password login is POST /login. JWT password login is POST /create. MapSharedAuthEndpoints<TUser>(sharedPath) maps the shared routes once. Null sharedPath uses the current builder (the app root when called on the app). Each Map* returns IEndpointConventionBuilder.
MapSignInEndpoints maps passkey register on the session prefix and does not map confirm-email. The register handler still writes the confirmation link with the shared confirm-email endpoint name. Mapping passkey register without MapSharedAuthEndpoints (or MapConfirmEmailEndpoints) leaves that link pointing at a route that was never mapped. MapPasskeySignUpEndpoints maps confirm-email itself when that name is new.
app.MapGroup("/web").MapSignInEndpoints<AppUser>(AuthEndpointsSession.Cookie);
app.MapGroup("/app").MapSignInEndpoints<AppUser>(AuthEndpointsSession.Jwt);
app.MapSharedAuthEndpoints<AppUser>();
Confirm-email keeps one endpoint name, so it is mapped with the shared routes and not under each session prefix. Password sign-in (POST /login or POST /create) records that password sign-in is mapped. Sign-up and recovery do not.
Set a password
No breaking change. MapAccountManagementEndpoints and MapIdentityManagementApi map POST /manage/password. MapAuthEndpoints leaves it unmapped. Changing a password stays on POST /manage/info.
The new route sets a password when the account has none. It requires a sign-in, a CSRF token, and a ReAuth proof, and it uses the AuthEndpoints.AccountAbuse rate limit. It returns 404 when password sign-in is not mapped, including for an anonymous caller. It returns 400 UserAlreadyHasPassword when a password already exists, and 400 with the Identity password-rule code when newPassword fails validation. The account still has no password after that failure. Success is an empty 200. Adding the password rotates the security stamp and calls IAccountSecurityEmailSender<TUser>.SendPasswordSetAsync. AddAccountManagementEndpoints and AddJwtEndpoints register LoggingAccountSecurityEmailSender<TUser> when no sender is registered. Startup logs a warning while that logging sender is still the registration. Register an implementation to deliver the notice. The password is not included. A cookie sign-in is refreshed so that browser session stays. Other sessions that check the stamp end.
JWT refresh rotation and body transport
Migration. Serialize refresh calls. Add the AbsoluteExpiresAt migration before you deploy. Set AbsoluteLifetime when a session should outlive 14 days.
Hashed refresh tokens, rotation, reuse that revokes the family, the security-stamp bind, and logout revoke are unchanged from 3.4.1. Presenting a refresh token that was already rotated still revokes every token in that family, including the successor.
A lost concurrent refresh is new. Two callers that present the same still-live token at the same time are both signed out. The request that loses the update revokes the family, including the successor the other request stored. Serialize refresh calls so only one request presents a given token.
Each family has an absolute lifetime, measured from the original sign-in. The default is 14 days, the same as JwtSessionOptions.RefreshTokenLifetime. Rotation cannot extend it. A refresh after the cap returns 400. Set AbsoluteLifetime when a session should outlive that window. A successor then expires at the earlier of "now plus RefreshTokenLifetime" and the family's absolute end.
The security-stamp bind is unchanged. A password change, reset, or UpdateSecurityStampAsync revokes the family. Logout revokes the family on the server. Deleting the cookie does not.
Hosts that call UseRefreshToken() add a migration and apply it before deploying. The model adds AbsoluteExpiresAt (non-null datetime) on AuthEndpoints.AuthEndpointsRefreshTokens. Backfill existing rows from ExpiresAt so a stored token cannot outlive the expiry it already has.
dotnet ef migrations add AuthEndpointsRefreshAbsoluteLifetime --project YourDataProject --startup-project YourHost
Refresh token in the JSON body
JwtSessionOptions.RefreshTokenTransport defaults to Cookie. Body is for native and CLI clients.
builder.Services.AddJwtEndpoints<AppUser, AppDbContext>(jwt =>
{
jwt.Issuer = "https://example.com";
jwt.Audience = "https://example.com";
jwt.SigningOptions.SymmetricKey = key;
});
builder.Services.Configure<JwtSessionOptions>(session =>
{
session.RefreshTokenTransport = RefreshTokenTransport.Body;
});
In body mode, POST /create, email-code POST /emailCode/verify, passkey register and login, and POST /refresh include refreshToken. POST /refresh and POST /logout accept { "refreshToken" }. Those two routes do not require antiforgery, and GET /csrfToken is not mapped. Cookie mode keeps the HttpOnly cookie AuthEndpoints.Jwt.RefreshToken, antiforgery on refresh and logout, and no refreshToken field in the JSON. GET /verify stays 204 and does not return a refresh token.
IdentityBearer refresh tokens are unchanged. They are self-contained, have no reuse detection, and end only when the security stamp changes.
External browser sign-in still redirects and does not put a refresh token in the URL. The shared Jwt completer and JwtExternalLoginCompleter both refuse RefreshTokenTransport.Body before they create a refresh token. The shared completer throws when the external map runs. The legacy completer fails startup validation. Use cookie transport for that session.
Enumeration-safe responses
No breaking change. 3.4.1 already answered POST /register with an empty 200 when the email already had an account. The body has no DuplicateUserName and no DuplicateEmail. Keep the same check-your-email screen.
A valid password for an existing email does not create a second account. 4.0 notifies that owner through IAccountSecurityEmailSender<TUser>.SendRegistrationAttemptedAsync. The interface is new in 4.0. The attempted password is left out of the message. AddPasswordSignUpEndpoints, AddAccountManagementEndpoints, and AddJwtEndpoints register LoggingAccountSecurityEmailSender<TUser> when no sender is registered. That default logs the attempt and does not send mail. Register an implementation to deliver it. Startup logs a warning while the logging sender is still the registration.
Invalid email and password-rule failures stay 400. Those bodies match whether or not the email is registered.
POST /forgotPassword, POST /resendConfirmationEmail, POST /emailCode/send, and POST /passkeys/register/options return the same status, body shape, and headers for an email that has an account and an email that does not. The unknown-account branch runs the same class of work as the account branch: a password-reset token, a confirmation token, an email-code issue, or passkey creation options. Enumeration-safe responses are always on.
Queue confirmation, reset, and registration-attempt mail and return from the sender. A sender that waits on SMTP makes a known address slower than an unknown one.
POST /resendConfirmationEmail still ignores the email in the body and resends for the signed-in user. POST /forgotPassword still sends a reset code only when the account exists and the email is confirmed.
Password facade
No breaking change. AddPasswordAuth / MapPasswordAuth are new. AddAuthEndpoints / MapAuthEndpoints stay the 3.4.1 facade: same options, same AuthEndpointsSignIn values, same EmailCode.Enabled switch, and the same /identity, /account, and /auth routes. They are not obsolete. The exceptions are in MapAuthEndpoints exceptions. There is no multi-session facade and no other scenario facade in 4.0.
New hosts that want one password session call AddPasswordAuth and MapPasswordAuth. Cookie is the default. JWT is allowed. Identity bearer is not. Startup validation fails when Session is Jwt and Jwt is null, when Session is IdentityBearer, and when the host calls both AddPasswordAuth and AddAuthEndpoints. The call and the building-block expansion are on Password auth. Option defaults are in Configuration options. The facade maps POST /manage/password. It does not map email-code sign-in or external sign-in.
External OAuth types live in AuthEndpoints.dll
Migration. Upgrade the GitHub and Google packages to 4.0.0. Remove AuthEndpoints.External.OAuth, or upgrade it to the 4.0.0 shim.
The external endpoint types moved into AuthEndpoints.dll. The namespace stays AuthEndpoints.External.OAuth. No using changes.
Upgrade AuthEndpoints.OAuth.GitHub and AuthEndpoints.OAuth.Google to 4.0.0. Those packages reference AuthEndpoints directly. Remove AuthEndpoints.External.OAuth, or upgrade it to the 4.0.0 forwarding shim. The shim assembly is still named AuthEndpoints.External.OAuth. It contains [TypeForwardedTo] for the public types and no implementation. Source that referenced the old package compiles against the shim, and the compiler emits references to AuthEndpoints.
AuthEndpoints, the shim, and the GitHub and Google packages are 4.0.0. The shim and the provider packages depend on AuthEndpoints with the range [4.0.0, 5.0.0).
External.OAuth below 4.0.0 fails the build (AE4000)
Migration. Remove every AuthEndpoints.External.OAuth package below 4.0.0, or upgrade it to 4.0.0.
AuthEndpoints.External.OAuth below 4.0.0 defines the external types itself and depends on AuthEndpoints with an open-ended minimum. Together with AuthEndpoints 4.0 that pair defines the same types twice (CS0433).
AuthEndpoints ships buildTransitive/AuthEndpoints.targets. Restore and build fail with AE4000 when a PackageReference, a PackageVersion, or project.assets.json resolves AuthEndpoints.External.OAuth below 4.0.0. Remove the package reference, or upgrade it to 4.0.0.
The 4.0.0 shim warns AE4001: AuthEndpoints.External.OAuth is merged into AuthEndpoints 4.0. Remove the package reference; no code changes are needed. Hosts that treat warnings as errors can NoWarn AE4001 for this release. The shim is removed in 5.0.
OAuthTicketChain is public
Migration. Call the public OAuthTicketChain API.
OAuthTicketChain is public. InternalsVisibleTo for AuthEndpoints.External.OAuth is gone. Adapter authors call OAuthTicketChain.Seal. Code that reached it through the old friend assembly uses the public method.
List and link routes use AuthEndpoints sign-in schemes
Migration. Standard Identity and JWT hosts do not change. A custom default scheme has to be one of the AuthEndpoints primary schemes.
GET /logins, GET /link/{scheme}, and GET /link/{scheme}/callback call RequireAuthEndpointsUser(). The authorize data lists the AuthEndpoints primary schemes that are registered: the application cookie, Identity bearer, and JWT bearer. A host whose default scheme is something else no longer satisfies those routes. Standard Identity and JWT hosts are unchanged. DELETE /logins/{loginProvider}/{providerKey} already used that check, plus ReAuth and antiforgery.
Email code counts as a sign-in method
Migration. No host change. Passkey delete and external unlink succeed when mapped email-code sign-in and a confirmed email are the remaining method.
Passkey delete and external unlink keep at least one sign-in method. Methods are a password, each passkey, each external login, and email code. Email code counts only when email-code sign-in routes are mapped (POST /emailCode/verify) and the email is confirmed. Send-only mapping does not count. Refusal stays 400 problem last_signin_method.
An external-only user with a confirmed email can unlink that login when email-code sign-in is mapped. The same user is refused when those routes are not mapped, until a password, a passkey, or another login remains.
Provider packages only register handlers
Migration. Delete MapGitHubAuthEndpoints and MapGoogleAuthEndpoints. Map MapExternalAuthProvider or MapExternalAuthEndpoints.
MapGitHubAuthEndpoints<TUser> and MapGoogleAuthEndpoints<TUser> are removed. AddGitHub and AddGoogle stay. Routes are unchanged (login/github, login/google, the callbacks, /logins, and the link routes). Map them from the core:
var external = app.MapGroup("/auth/external");
external.MapExternalAuthProvider<AppUser>("GitHub");
external.MapExternalAuthProvider<AppUser>("Google");
external.MapExternalAccountEndpoints<AppUser>();
// or every registered provider:
external.MapExternalAuthEndpoints<AppUser>();
Pass AuthEndpointsSession or a completer name when the callback should finish as cookie, Identity bearer, or JWT. The default is the cookie completer. There is no two-factor step.
Compatibility composites
The 3.x composite names, signatures, and routes stay in 4.0. PasswordLoginMode stays. AddAuthEndpoints and MapAuthEndpoints stay the 3.4.1 facade.
What each composite maps, and which Add* and Map* pairs it contains, is in the composition table. MapAuthEndpoints calls the preserving helpers, so password login, JWT POST /create, and email-code verify stay on the 3.4.1 handlers. AddPasskeyEndpoints registers the password, cookie-session, and account-management modules, so MapIdentityManagementApi and MapCookieAuthEndpoints can follow that single call.
A missing Add* throws at map time
Migration. Call the Add* named in the exception before the Map*.
Every public Map* throws InvalidOperationException when its Add* was not called. The message names the method to call, for example Call AddPasswordEndpoints before MapPasswordEndpoints.
A host that previously started and failed on the first request now fails while endpoints are mapped. Call the named Add* before the Map*.
Passkey maps accept AddPasskeyEndpoints or the specific AddPasskeySignInEndpoints, AddPasskeySignUpEndpoints, AddPasskeyRegisterOptionsEndpoints, or AddPasskeyManagementEndpoints. MapConfirmEmailEndpoints names AddAccountAbuseRateLimiting. MapReAuthEndpoints names AddReAuthScheme. MapAuthEndpoints names AddAuthEndpoints. MapPasswordAuth names AddPasswordAuth. Selecting the Jwt completer names AddJwtEndpoints.
MapSignInEndpoints still logs a warning when no sign-in module is registered, then maps that session's routes. Those session routes throw when their Add* is missing.
Account-abuse and confirm-identity rate limits are public
Migration. No host change. Call AddAccountAbuseRateLimiting when the only map is MapConfirmEmailEndpoints.
AddAccountAbuseRateLimiting and AddConfirmIdentityRateLimiting are public. This is additive. Broader Add* methods still register the policies. Call AddAccountAbuseRateLimiting directly when the only map is MapConfirmEmailEndpoints.