Email-code module
Opt-in sign-in with a numeric code. AllowSignUp and the magic link default to off. Steps for turning the feature on are in Sign in with an emailed code.
DI
builder.Services.AddEmailCodeEndpoints<AppUser, AppDbContext>(options =>
{
options.CodeLength = 6;
});
AddEmailCodeEndpoints registers the store, options validation, the AuthEndpoints.EmailCode.Send rate-limit policy, the AuthEndpoints.Login policy, the AuthEndpoints.ConfirmIdentity.EmailCode policy, and IdentityEmailCodeSignInCompleter<TUser> (with TryAdd). In Development it registers DevelopmentEmailCodeSender<TUser> when no IEmailCodeSender<TUser> is registered. It also registers TimeProvider.System and UpperInvariantLookupNormalizer with TryAdd, so an existing Identity normalizer and clock stay in place.
Finish verify in a built-in session by passing AuthEndpointsSession to the map. JWT needs AddJwtEndpoints first:
app.MapGroup("/identity").MapEmailCodeEndpoints<AppUser>(AuthEndpointsSession.Jwt);
MapEmailCodeEndpoints<TUser>() with no session keeps the 3.x completer. That is an explicitly registered IEmailCodeSignInCompleter<TUser>, otherwise Identity bearer. AddAuthEndpoints uses that parameterless map when EmailCode.Enabled is true. That facade map ignores SignInCompletionOptions.Default. Jwt.Enabled does not change it.
AddEmailCodeSignInCompleter is obsolete. It still runs when the map passes no session. See Upgrade to 4.0.
Map
app.MapGroup("/identity").MapEmailCodeEndpoints<AppUser>();
The facade maps this group under IdentityPath (default /identity) when EmailCode.Enabled is true.
Pair it with MapCookieSessionEndpoints and MapAccountManagementEndpoints when the prefix should skip password register and login. That host is Email-code only. MapAccountManagementEndpoints then also maps POST /confirmIdentity/emailCode/send. POST /confirmIdentity accepts { "emailCode" } when step-up by email is available for that user.
MapEmailCodeEndpoints throws InvalidOperationException if AddEmailCodeEndpoints was not called. It throws NotSupportedException if the DbContext model has no EmailCodeEntry. Call modelBuilder.UseEmailCodes() from OnModelCreating and migrate. The table is AuthEndpoints.AuthEndpointsEmailCodes. It is not created unless UseEmailCodes() is called.
Routes
Relative to the group prefix (facade default /identity):
| Method | Path | Auth | CSRF | Rate limit |
|---|---|---|---|---|
POST | /emailCode/send | AuthEndpoints.EmailCode.Send | ||
POST | /emailCode/verify | AuthEndpoints.Login |
GET /emailCode/verify is not mapped, so it returns 405.
Send
Request: EmailCodeSendRequest, JSON { "email" }.
| Status | Body |
|---|---|
200 | EmailCodeSendResponse: { "expiresInSeconds", "resendAfterSeconds" } |
400 | ValidationProblem with Identity code InvalidEmail |
429 | Empty. The per-IP send limit rejected the request |
expiresInSeconds is CodeLifetime in whole seconds. resendAfterSeconds is ResendCooldown in whole seconds.
The 200 body is the same when the address is unknown, the user is locked out, the user is ineligible under UnconfirmedAccounts, the resend cooldown applies, MaxSendsPerEmail is reached, or SendSignInCodeAsync throws for a reason other than the request being cancelled. A cancelled request is not turned into 200.
Issuing the row runs inside Database.CreateExecutionStrategy(), so a context that uses EnableRetryOnFailure can send. A SQL Server deadlock (error 1205), a MySQL deadlock (error 1213), a Postgres serialization failure (40001) or deadlock (40P01), or a SQLite busy or locked error (5 or 6) is a conflict. The store tries that insert twice. When the store call throws the same conflict, send tries the call twice. That is up to four attempts. A provider execution strategy can retry inside each attempt. If the conflict remains, send returns the same 200 and does not send mail.
Verify
Request: EmailCodeVerifyRequest.
| Field | Role |
|---|---|
email | Required with code |
code | Required with email. Length must equal CodeLength, and every character must be a digit |
token | Required alone when the magic link is on. Do not send email or code with it |
twoFactorCode | TOTP, when the user has two-factor authentication on |
twoFactorRecoveryCode | Recovery code. Redeemed after the row is consumed. A failed redeem clears ConsumedAt and leaves the attempt |
Query useCookies and useSessionCookies are passed to the completer named by MapEmailCodeEndpoints.
| Session | 200 body | Flags |
|---|---|---|
Cookie | Empty. The application cookie is set. | Persistent only when useSessionCookies is false. useCookies is ignored. Omit the flag for a session cookie. |
IdentityBearer | Identity bearer token JSON when neither flag is set. Empty when a cookie is set. | A cookie only when useCookies or useSessionCookies is true. Persistent only when useCookies is true and useSessionCookies is not true. |
Jwt | { "accessToken", "tokenType": "Bearer" } and the AuthEndpoints.Jwt.RefreshToken cookie. | Both flags are ignored. |
An explicitly registered 3.x completer follows its own rules when the map passes no session. IdentityEmailCodeSignInCompleter<TUser> matches the IdentityBearer row. JwtEmailCodeSignInCompleter<TUser> matches the Jwt row.
| Status | Body |
|---|---|
200 | Empty when the result is an application cookie. Otherwise the completer's token JSON |
400 | ValidationProblem InvalidEmailCodeRequest, description Provide an email and a numeric code. |
401 | Problem details, title Unauthorized, detail Invalid or expired code. |
401 | Problem details, title RequiresTwoFactor, detail Two-factor authentication is required. |
429 | Empty. The login rate limit rejected the request |
Invalid or expired code. covers a wrong code, a wrong token, an expired or consumed row, an attempt cap, a lockout, a failed second factor, a failed sign-up, a failed credential reset, and CanSignInAsync returning false.
RequiresTwoFactor is returned only when RequireTwoFactor is true and GetTwoFactorEnabledAsync is true, and the body has neither second-factor field. That response releases the reserved attempt, so the same code can be sent again with a factor. A wrong factor keeps the attempt.
Options
EmailCodeOptions, set from AuthEndpointsEmailCodeOptions.Configure or the AddEmailCodeEndpoints callback:
| Property | Default | Validation |
|---|---|---|
CodeLength | 6 | 6 through 10 |
CodeLifetime | 10 minutes | 1 minute through 60 minutes |
MaxAttempts | 5 | 1 through 10 |
ResendCooldown | 60 seconds | 0 through 10 minutes |
MaxSendsPerEmail | 5 | At least 1 |
SendWindow | 1 hour | Greater than zero |
SendsPerIpPerMinute | 5 | At least 1. This is the fixed-window IP limit |
AllowSignUp | false | When true, an unknown address can receive a code that creates a user |
RequireTwoFactor | true | When true, a user with two-factor authentication on must present a TOTP or recovery code |
UnconfirmedAccounts | ConfirmAndResetCredentials | Reject refuses an unconfirmed account that already has a password, external login, or passkey |
CountFailuresTowardLockout | true | A wrong code or wrong second factor calls AccessFailedAsync when a user exists |
RequireSenderOutsideDevelopment | true | Outside Development, startup fails without a real IEmailCodeSender<TUser> |
ReAuth | enabled, 5 minutes | See below |
MagicLink | disabled | See below |
EmailCodeReAuthOptions:
| Property | Default | Notes |
|---|---|---|
Enabled | true | When false, authMethods.emailCode is false and send returns 400 EmailCodeProofUnavailable |
CodeLifetime | 5 minutes | 1 minute through 10 minutes. Sign-in codes keep CodeLifetime (default 10 minutes). The proof is offered only when the user has no password, no authenticator, and no passkey. An external login is not a stronger factor |
EmailCodeMagicLinkOptions:
| Property | Default | Notes |
|---|---|---|
Enabled | false | When false, any token in the verify body is 400 |
LinkUri | null | Required when Enabled is true. An absolute http(s) URI with no #. A rooted path such as /auth/email-link is accepted when BuildLinkUrl is set |
AllowedOrigins | empty | Required in Production. Each entry is an origin |
BuildLinkUrl | null | Func<EmailCodeLinkContext, string>. When null, LinkUrl is LinkUri + "#token=" + token |
Startup rejects a missing LinkUri, a relative path, a LinkUri that contains #, a protocol-relative URI, and a non-http(s) scheme. A rooted path is rejected unless BuildLinkUrl is set. An absolute URI still has to use https in Production, and its origin has to be in AllowedOrigins, including when BuildLinkUrl is set.
Facade flag AuthEndpointsEmailCodeOptions.Enabled defaults to false. Configure is Action<EmailCodeOptions>.
Sender
public interface IEmailCodeSender<TUser> where TUser : class
{
Task SendSignInCodeAsync(EmailCodeMessage<TUser> message, CancellationToken cancellationToken = default);
}
EmailCodePurpose is SignIn (0), SignUp (1), or ReAuth (2). Code and LinkUrl are raw. Encode them when the template is HTML. ReAuth is a step-up message ("confirm it's you"), not a sign-in, and LinkUrl is null. POST /emailCode/send awaits SendSignInCodeAsync, so queue the message and return. The library log category is AuthEndpoints.EmailCode. Those logs include a user id and an email-hash prefix. They do not include the address, the code, the token, or LinkUrl. DevelopmentEmailCodeSender<TUser> logs the raw code at warning. Do not register it outside Development.
Sign-up and unconfirmed accounts
With AllowSignUp true, verify of a correct code for an unknown address creates TUser with UserName and Email set to the trimmed address, sets EmailConfirmed to true before CreateAsync, and sets EmailCodeSignInContext.IsNewUser to true. It does not use the email-confirmation token provider, does not set a password, and does not call IEmailSender<TUser>. A duplicate create, including a DbUpdateException from a concurrent register, uses the user that already has that email and sets IsNewUser to false. When that user is already confirmed, the password stays in place. If the lookup finds no user, verify returns 401. A duplicate sign-up that still needs two-factor authentication releases the reserved attempt and does not consume the code.
ConfirmAndResetCredentials removes the password, each external login, and each passkey, then calls UpdateSecurityStampAsync, and only then confirms the email. If any removal or the stamp update fails, verify returns 401 and does not confirm the email.
Storage
UseEmailCodes() maps EmailCodeEntry to AuthEndpoints.AuthEndpointsEmailCodes.
| Column | Contents |
|---|---|
Purpose | 0 sign-in (including sign-up messages) or 1 step-up. Default 0, so existing rows stay sign-in |
EmailHash | SHA-256 hex of the address after trim and ILookupNormalizer.NormalizeEmail |
CodeHash | SHA-256 hex of Id + ":" + code |
LinkTokenHash | SHA-256 hex of the token, or null when the magic link is off |
SignupEmail | The trimmed address for a sign-up row, so a token-only verify can create the user. null on every other row |
UserId, SecurityStamp | Set for an eligible existing user. A later stamp or email change makes the row fail verify |
Attempts | Incremented by a conditional update before the code is compared |
ConsumedAt, SupersededAt, ExpiresAt | Consume, replace, and expiry marks |
The code compare uses a fixed-time equality check. The unique index on LinkTokenHash has no filter. The non-unique index is (EmailHash, Purpose, CreatedAt), named IX_AuthEndpointsEmailCodes_EmailHash_Purpose_CreatedAt. Cooldown, the per-address send cap, supersede, and retention of expired rows are limited to that purpose. A step-up row stores UserId and SecurityStamp, and it never stores LinkTokenHash or SignupEmail. Sign-in verify ignores step-up rows, including by link token.
Expiry, cooldown, and the send cap use TimeProvider.