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 onPasskeysandTwoFactor(#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.returnTois display and navigation only: it never mints, spends or selects a proof. returnToinside step-up. Step-up has noreturnToparameter of its own. It reads the value from thereturnToinside its Identity-localreturnUrl(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'sreturnTointo thatreturnUrl, so completing step-up returns to the same page with it. An emailed confirmation restores the samereturnUrlfrom the stored grant.
See ADR-022 for the hub architecture and the full return-to-app contract.