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