AUTH4D-262 verified email step: the gate in /oauth/authorize/continue

Before POST /oauth/authorize/continue issues an authorization code, it decides whether the application’s verified email step applies to this sign-in. If it does, no code is issued: the whole authorization request is parked server-side and hosted login is sent to the step page. The wire shapes are the AUTH4D-258 contracts (auth4d-258.md (repository file docs/api-spec/auth4d-258.md)); the design is docs/architecture/verified-email-step.md (repository file docs/architecture/verified-email-step.md).

When the step applies

The gate runs after the identity proof is consumed and the user is loaded, and before the post_login hook, a terms screen, consent and the code. Inputs are the application’s requireVerifiedEmail (AUTH4D-260), the user’s identities, and the sign-in facts on the proof (AUTH4D-261). An Auth4-verified address is an identity with provider email, a non-null address and email_verified = 1 (email code, magic link, password, or the step itself).

Sign-in off when_missing always
Any, user has an Auth4-verified address no no no
Steam, Discord, Epic, Google, Apple, Microsoft, OIDC, SSO no when the upstream did not vouch for an address now yes
Reused hosted session, passkey or SMS, user has a social identity no when no identity of the user has a verified address yes
Reused hosted session, passkey or SMS, user has no social identity no no no
Email code, magic link, password no no (the sign-in itself proves the address) no

The reason recorded with the step is always under always; otherwise unverified_email when the sign-in identity has an address the upstream did not verify, and no_email when it has none.

Not gated: a guest-linking flow, whose redirect URI is the hosted login itself (guest upgrade through the step is a follow-up), the hosted guest start (/t/{tenantId}/guest/authorize), and hosted device approval (a later ticket).

Parked answer

With Accept: application/json (hosted login), continue answers 200:

{ "interaction": { "type": "verify_email", "step": "est_…", "expires_in": 600 } }

and sets __Host-auth4_email_step=<secret>; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=Lax. Without the JSON Accept, it answers 302 to /login/verify-email?tenant_id=…&client_id=… with the same cookie. Neither answer holds a code, a token or a hosted-login session cookie, and the step handle never appears in a URL. The flow cookie is kept, so a terms screen after the step can continue the same flow. Responses are no-store.

Pending step

Stored in the tenant’s Durable Object (kind challenge, key email-step:<SHA-256(handle)>) for 600 seconds. It holds the client, redirect URI, PKCE challenge, nonce, state, scopes, the flow id, its cookie hash and start time, the user id, authTime, isFirstSignIn, the method, the sign-in facts (idp, upstreamEmailVerified, identityCreated), the proof’s identity id, the reason, and the SHA-256 of the cookie secret. No code, token or secret is stored.

readEmailStep (in apps/api/src/modules/email-step) reads it for the browser holding the cookie: peek leaves it in place, consume removes it so it is used once. A missing or wrong cookie, another tenant or another application reads as missing, and the step is left for the browser that owns it; an expired step is dropped. The step endpoints answer all of these with 410 step_expired.

resumeAuthorization(step) (in apps/api/src/modules/oauth) finishes a consumed step: it checks the application, the user and the gate again (a step that is still unsatisfied answers 403 access_denied with no code), then runs the same post_login hook, terms screen, consent and code issuance as continue, and clears the step and flow cookies.

prompt=none

A silent request cannot show the step. When it would apply to the session’s user, /oauth/authorize redirects to the application with error=interaction_required and the state, so the SDK falls back to an interactive sign-in.

Source: docs/api-spec/auth4d-262.md