AUTH4D-258 verified email step: contracts
This fragment freezes the wire shapes for the verified email step in hosted login. The design is
docs/architecture/verified-email-step.md (repository file docs/architecture/verified-email-step.md). The schemas live
in packages/contracts/src/index.ts (settings, endpoints, audit) and packages/contracts/src/sdk.ts
(the idp claim). AUTH4D-258 adds contracts only. The endpoints, the gate in
/oauth/authorize/continue, migration 0037 and the claims are built by later tickets of the epic,
which update this page when they ship.
Settings
| Setting | Where | Values | Default |
|---|---|---|---|
requireVerifiedEmail |
Application (ClientPolicySchema, create and update) |
always, when_missing, off |
when_missing |
linkVerifiedEmail |
Project settings (ProjectEmailLinkingSettingsSchema) |
never, after_code |
never |
Both fields are optional on the wire; absent means the default (DEFAULT_REQUIRE_VERIFIED_EMAIL,
DEFAULT_LINK_VERIFIED_EMAIL).
when_missingasks a social sign-in for an email code when the user has no email identity that Auth4 verified and the provider gave no address or an unverified one.alwaysasks whenever the user has no email identity that Auth4 verified, whatever the provider says.offnever asks.after_codeattaches a social identity created in this same sign-in, before any token was issued, to the existing user who owns the address the step just proved. Existing identities never move, sosubnever changes for an existing user. Every other conflict answersemail_unavailable.
Starting the step
When the step is required, POST /oauth/authorize/continue (JSON mode) answers 200 with an
interaction instead of redirect_to, and sets __Host-auth4_email_step (HttpOnly; Secure; SameSite=Lax; Path=/, 600 seconds). The cookie’s secret binds the step to this browser.
{ "interaction": { "type": "verify_email", "step": "est_…", "expires_in": 600 } }
Hosted login then opens /login/verify-email?tenant_id=…&client_id=…. The step handle
(est_ and 43 base64url characters) is not a secret and travels only in request bodies, never in
a URL. A step lives for EMAIL_STEP_EXPIRY_SECONDS (600 seconds) and is consumed once.
Endpoints
All are POST /t/{tenantId}/email-step/<action> with a JSON body, the step cookie and an Origin
equal to the auth origin. Every response is JSON with Cache-Control: no-store. Each body has
client_id and step; extra fields are refused.
| Action | Extra body fields | 200/202 response |
|---|---|---|
state |
none | EmailStepStateResponseSchema |
send |
email |
202 { "expires_in": 600, "resend_after": 60 }, always the same |
resend |
none | 202 { "expires_in": 600, "resend_after": 60 }, always the same |
confirm |
code (6 digits) |
{ "redirect_to" }, { "redirect_to", "denial" } or { "interaction" } |
abandon |
none | { "redirect_to" }: the application receives access_denied |
state answers what the page shows when it opens or reloads:
{
"step": "est_…",
"client_id": "cli_…",
"idp": "steam",
"reason": "no_email",
"expires_in": 540,
"masked_address": "p***@e***.com",
"resend_after": 42
}
reason is no_email, unverified_email or always. masked_address and resend_after appear
together, once a code has been sent. A masked address shows one character of the local part and of
the domain, then the top-level labels, so it can never hold a whole address or a code.
send and resend reuse the AUTH4D-13 rules: 6-digit codes stored as hashes, a 10-minute expiry,
single use, at most 5 wrong attempts, a 60-second resend cooldown, and limits per address and per
IP. Like AUTH4D-13 they always answer the same body, even when throttled.
confirm consumes the step on success, adds the address as an email identity of the user (or
links under after_code), and continues exactly as /oauth/authorize/continue would.
Errors
4xx answers carry only a code, { "error": "…" }, plus retry_after (seconds) with slow_down.
Hosted login shows its own text and never a server description.
error |
When |
|---|---|
invalid_code |
The code is wrong |
code_expired |
The code is older than 10 minutes or was replaced by a resend |
too_many_attempts |
5 wrong codes; send a new code |
slow_down |
A resend inside the cooldown, or a rate limit |
email_unavailable |
409, only after the code is proven; the step stays open for another address |
step_expired |
410: the step is missing, expired, consumed or bound to another browser |
The idp claim
ID tokens and userinfo gain an optional idp string naming the method used for this sign-in. The
access-token contract accepts it too, so SDKs keep working if a later release adds it there.
Issuers emit only IdpSchema values:
email, password, magic_link, passkey, sms, discord, steam, epic, google, apple,
microsoft, oidc, sso, guest, session.
Email codes, magic links and passwords all issue email_verification proofs today, so they map to
email (IDP_BY_PROOF_PURPOSE). The step never changes idp: a Steam sign-in that passed it is
still steam. Token readers accept any value matching ^[a-z][a-z0-9_]{1,31}$
(IdpClaimSchema), so a method added later does not make older SDKs refuse a token. The C#, Go and
Python server SDKs accept idp in access and ID tokens and refuse a malformed one.
Audit events
email_step.code_sent, email_step.verified, email_step.linked, email_step.failed and
email_step.abandoned. AuditEventSchema refuses any other email_step.* name, and their metadata
may hold only client_id, idp, identity_id, reason and masked_address
(EmailStepAuditMetadataSchema). No code, code hash or whole address fits.
Source: docs/api-spec/auth4d-258.md