Skip to content

Emailed confirmation for sign-in and two-step 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 operation recorded by the existing server-issued request (see the operations below). 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.

Operations that require step-up

Each guarded handler is an antiforgery-protected POST. Without a proof for exactly its target it changes nothing. Instead it issues a data-protected step-up request naming that target and redirects to /Account/Manage/StepUp. Completing any step-up method (password, passkey or emailed link, each followed by configured MFA) mints a five-minute proof for that target only. The proof is an HttpOnly, Secure, SameSite=Lax cookie whose path is the one page that spends it. The person returns to that page and selects the same control again. That POST deletes the cookie before it mutates anything, so a failed change needs a fresh step-up.

Operation (StepUpOperation) Handler Target binding Proof cookie path
Link ExternalLogins Link, then the provider callback Provider scheme /Account/Manage/ExternalLogins
Unlink ExternalLogins Unlink Provider scheme and provider key /Account/Manage/ExternalLogins
RemovePasskey (#4116) Passkeys Remove Credential ID (canonical base64url) /Account/Manage/Passkeys
DisableTwoFactor (#4116) TwoFactor Disable The account /Account/Manage/TwoFactor
ResetAuthenticator (#4116) TwoFactor Reset The account /Account/Manage/TwoFactor
  • A proof for one row never satisfies another, including two passkeys or the two two-step changes. Proofs are also bound to the account and its security stamp. Turning two-step off and resetting the authenticator both rotate the stamp, so their proofs also stop validating on the server once spent.
  • The checks that need no step-up run first. Removing the last passkey from an account with no password and no external sign-in is still refused, and is refused before step-up is offered.
  • After step-up, the Passkeys and TwoFactor pages read the live proof to ask for the one remaining confirmation. No credential ID or operation travels in a URL.
  • Enabling two-step verification, adding a passkey and changing a password are not gated by this mechanism. Adding a passkey and removing passkeys on password reset remain open items under ID-6 of the October 2026 backend plan.
  • With two-step verification on, step-up asks for an authenticator or recovery code. An account whose authenticator key is missing therefore needs a recovery code to reset its authenticator setup.

Emailed confirmation

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.

Entry from the My Account Security hub (account portal slice 2)

Under OpenIddict, My Account → Security (/manage/security) links each sign-in method change to GET /api/auth/account/{page}?returnTo=/manage/security, which redirects to the Identity page (ExternalLogins, Passkeys or TwoFactor). The hub never posts to Identity and never starts a step-up itself.

  • Step-up is unchanged by the hub. Link and unlink on ExternalLogins, and passkey removal and the two-step changes on Passkeys and TwoFactor (#4116), issue a step-up request from their antiforgery-protected POST handlers and spend an operation-bound proof, whether or not the person came from the hub. returnTo is display and navigation only: it never mints, spends or selects a proof.
  • returnTo inside step-up. Step-up has no returnTo parameter of its own. It reads the value from the returnTo inside its Identity-local returnUrl (AppReturnPath.FromLocalUrl, normalised), so the "Back to SyRF" link and the "Return without changing anything" link on a failed or abandoned step-up both keep the way back to the hub. Every guarded handler puts the page's returnTo into that returnUrl, so completing step-up returns to the same page with it. An emailed confirmation restores the same returnUrl from the stored grant.

See ADR-022 for the hub architecture and the full return-to-app contract.