Skip to content

Emailed confirmation for external sign-in changes

The Identity step-up page offers a single-use emailed primary factor to signed-in users with a locally confirmed mailbox. It supplements password and passkey reauthentication for the external link/unlink operation recorded by the existing server-issued request. It is not an anonymous sign-in or account-recovery endpoint. Availability in a deployed environment still depends on its Identity rollout and SES configuration.

An antiforgery-protected request reserves one challenge per account in the Identity Mongo database's EmailStepUpChallenges collection. A conditional upsert against the unique account ID permits one send per minute across replicas. Each successful reservation replaces the previous challenge, so an older email arriving late cannot authorize anything. Failed or ambiguous delivery retains the cooldown and produces the same waiting message. Existing password/passkey paths remain available.

The credential is 32 random bytes encoded as base64url; only its SHA-256 hash is stored. The document binds the account, security stamp, confirmed email, original protected step-up request and local continuation. Links use the configured issuer, never an incoming Host header, and are built beneath the issuer's path, so an issuer such as https://host/prefix/ yields https://host/prefix/Account/Manage/StepUp.

The credential is carried only in the link's URL fragment (…/Account/Manage/StepUp#EmailToken=…). Browsers never send a fragment, so the GET request target seen by the ingress access log, proxies and mail-scanner fetches is the bare page path. The page script copies the value into the hidden field of the confirmation form, removes it from the address bar and history entry with history.replaceState, and reveals the form. EmailToken binds from the form body only ([FromForm]); a query-string value on the GET or on the confirmation POST is ignored and consumes nothing. Without JavaScript the confirmation form stays hidden and the password method remains available.

A signed-out click is redirected to sign-in, and browsers carry the fragment onto the login URL, where nothing removes it. The credential never reaches the server on that path either, and after signing in the user lands on the page without it, so the flow fails closed and the user requests a new link. Until it expires five minutes after issue, the unused credential stays visible in that tab's address bar and history.

A five-minute expiry is enforced in the consume query; the one-day TTL is storage cleanup, not the security boundary.

GET renders the page with no-store and no-referrer headers and never receives or consumes the credential. An authenticated antiforgery-protected POST checks sign-in eligibility and lockout, then atomically consumes the matching challenge with majority write concern. Missing, expired, replayed, malformed and mismatched credentials yield no proof and never reach MFA verification. A store error also fails closed without logging its message or the link.

After consumption, configured authenticator or recovery-code verification uses the existing step-up implementation and failed-guess accounting. A failed MFA attempt spends the link; the user requests another or chooses another method. Only completion mints the existing five-minute operation-bound proof. This flow does not sign in another account, refresh the sign-in cookie, alter session versions, or manufacture downstream auth_time/amr evidence.

Verification covers Mongo concurrency and expiry boundaries, original target and continuation preservation, user/stamp/mailbox changes, malformed credentials, delivery failures, configured MFA and lockout. A real-host HTTP test verifies two GETs and a rejected CSRF POST leave the challenge usable, the delivered link has no query and its request target never carries the credential, one valid POST issues the proof, replay fails, and captured logs never contain the credential or link. Issuer base-path preservation is covered for origin and prefixed issuers. All email delivery in tests is intercepted.