Changelog
Reference

Email-code module

Reference for emailed one-time codes, optional sign-up, and optional magic links.

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):

MethodPathAuthCSRFRate limit
POST/emailCode/sendAuthEndpoints.EmailCode.Send
POST/emailCode/verifyAuthEndpoints.Login

GET /emailCode/verify is not mapped, so it returns 405.

Send

Request: EmailCodeSendRequest, JSON { "email" }.

StatusBody
200EmailCodeSendResponse: { "expiresInSeconds", "resendAfterSeconds" }
400ValidationProblem with Identity code InvalidEmail
429Empty. 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.

FieldRole
emailRequired with code
codeRequired with email. Length must equal CodeLength, and every character must be a digit
tokenRequired alone when the magic link is on. Do not send email or code with it
twoFactorCodeTOTP, when the user has two-factor authentication on
twoFactorRecoveryCodeRecovery 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.

Session200 bodyFlags
CookieEmpty. The application cookie is set.Persistent only when useSessionCookies is false. useCookies is ignored. Omit the flag for a session cookie.
IdentityBearerIdentity 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.

StatusBody
200Empty when the result is an application cookie. Otherwise the completer's token JSON
400ValidationProblem InvalidEmailCodeRequest, description Provide an email and a numeric code.
401Problem details, title Unauthorized, detail Invalid or expired code.
401Problem details, title RequiresTwoFactor, detail Two-factor authentication is required.
429Empty. 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:

PropertyDefaultValidation
CodeLength66 through 10
CodeLifetime10 minutes1 minute through 60 minutes
MaxAttempts51 through 10
ResendCooldown60 seconds0 through 10 minutes
MaxSendsPerEmail5At least 1
SendWindow1 hourGreater than zero
SendsPerIpPerMinute5At least 1. This is the fixed-window IP limit
AllowSignUpfalseWhen true, an unknown address can receive a code that creates a user
RequireTwoFactortrueWhen true, a user with two-factor authentication on must present a TOTP or recovery code
UnconfirmedAccountsConfirmAndResetCredentialsReject refuses an unconfirmed account that already has a password, external login, or passkey
CountFailuresTowardLockouttrueA wrong code or wrong second factor calls AccessFailedAsync when a user exists
RequireSenderOutsideDevelopmenttrueOutside Development, startup fails without a real IEmailCodeSender<TUser>
ReAuthenabled, 5 minutesSee below
MagicLinkdisabledSee below

EmailCodeReAuthOptions:

PropertyDefaultNotes
EnabledtrueWhen false, authMethods.emailCode is false and send returns 400 EmailCodeProofUnavailable
CodeLifetime5 minutes1 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:

PropertyDefaultNotes
EnabledfalseWhen false, any token in the verify body is 400
LinkUrinullRequired 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
AllowedOriginsemptyRequired in Production. Each entry is an origin
BuildLinkUrlnullFunc<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.

ColumnContents
Purpose0 sign-in (including sign-up messages) or 1 step-up. Default 0, so existing rows stay sign-in
EmailHashSHA-256 hex of the address after trim and ILookupNormalizer.NormalizeEmail
CodeHashSHA-256 hex of Id + ":" + code
LinkTokenHashSHA-256 hex of the token, or null when the magic link is off
SignupEmailThe trimmed address for a sign-up row, so a token-only verify can create the user. null on every other row
UserId, SecurityStampSet for an eligible existing user. A later stamp or email change makes the row fail verify
AttemptsIncremented by a conditional update before the code is compared
ConsumedAt, SupersededAt, ExpiresAtConsume, 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.