AUTH4D-145 One-time sign-in codes by SMS

Browser applications can let players sign in with a six-digit code sent by SMS. It is opt-in per application, limited to a per-application country allowlist, and off for every application until a project owner or admin turns it on. A deployment without a configured SMS provider never offers it. Wire shapes are the Sms* and ApplicationSmsSettings* schemas in packages/contracts.

Phone identities are stored in identities with provider = 'phone' and the E.164 number as provider_subject, unique per tenant (migration 0024_sms_sign_in.sql). They carry no email address. The number is the only personal data stored; codes exist only as keyed hashes in the tenant’s challenge Durable Object.

Start

POST /t/{tenantId}/sms/verification/start

{ "client_id": "cli_…", "browser_flow_id": "flw_…", "phone_number": "+27 82 555 0123" }

The request must come from the auth origin (Origin equal to AUTH_ORIGIN) with the __Host-auth4_flow_{flowId} cookie of a live authorization flow for this tenant and client. The tenant must be active, the client an enabled browser client with the authorization_code grant, and SMS enabled for the client. Guest-linking flows (which point at the hosted login) and device sign-in flows are refused. The number is normalized (spaces, dots, dashes and parentheses removed, 00 prefix read as +) and must be E.164. Its country is resolved from the calling code; in the shared +1 zone only United States and Canadian geographic area codes resolve, so Caribbean, territory, toll-free and premium-rate numbers never do.

Status Body When
202 { "expires_in": 600, "resend_after": 60 } A code was sent; any earlier code for this flow and number stops working
400 { "error": "invalid_request" } Malformed body, unknown or unsuitable application
400 { "error": "invalid_phone_number" } Not an international number
400 { "error": "unsupported_country" } The number’s country is not on the application’s allowlist
403 { "error": "access_denied" } Wrong origin, missing or mismatched flow cookie, SMS off
429 { "error": "rate_limited" } + Retry-After A send budget is spent
503 { "error": "temporarily_unavailable" } No SMS provider configured, or the provider refused the message

Send budgets, all checked before any message is sent (SMS_SIGN_IN_LIMITS):

Budget Limit
Per flow and number (resend cooldown) 1 per 60 s
Per number in the tenant 3 per hour
Per number across the deployment 6 per day
Per client address in the tenant 5 per hour
Per client address across the deployment 15 per day
Per tenant 100 per hour, 500 per day
Per deployment 2,000 per hour

Rate-limit keys carry only keyed hashes of the number and address. When the provider refuses a message the stored code is removed, so it can never be redeemed.

The message is fixed text and names no tenant or application. Its last line is the origin-bound one-time-code format, @{auth host} #{code}.

Confirm

POST /t/{tenantId}/sms/verification/confirm

{
  "client_id": "cli_…",
  "browser_flow_id": "flw_…",
  "phone_number": "+27825550123",
  "code": "123456"
}

The same origin, flow-cookie and application checks apply, and SMS must still be enabled with the number’s country allowed. Each client address may make 20 attempts per hour and each code 5. Wrong, expired, replaced and mismatched codes, a number of another flow, and an unknown number while the project has sign-up turned off all return 400 { "error": "invalid_code" }. A wrong guess keeps the code usable until its attempts are spent.

On success the number resolves to the tenant’s phone identity, or a new user with one, and the response is a one-time identity proof:

{ "proof": { "proof_id": "prf_…", "expires_in": 300 } }

The hosted login passes it to /oauth/authorize/continue, which now accepts proof_purpose sms_verification alongside email_verification, discord_login and steam_login.

Login context

GET /t/{tenantId}/login/context adds login_methods.sms: true and a top-level sms_countries array of ISO 3166-1 alpha-2 codes when the browser application has SMS enabled and the deployment has an SMS provider. Both are absent otherwise, so earlier responses are unchanged.

Management

Routes live under /management/tenants/{tenantId}/clients/{clientId} and use the usual management token, per-actor budget and project-role checks.

GET /sms (scope client:read) and PUT /sms (scope client:write, owners and admins):

{ "enabled": true, "allowed_countries": ["GB", "ZA"] }

Responses are

{ "sms": { "enabled": true, "allowed_countries": ["GB", "ZA"], "provider_configured": true } }

allowed_countries is de-duplicated and sorted; every entry must be a country listed in packages/sms (SMS_COUNTRIES). Enabling needs at least one country and a browser application; anything else is 400 validation_failed with field details. Unknown applications are 404, confidential system clients 403 reserved_client. Every PUT records management.client.sms.updated with status and country_count metadata only.

Provider configuration

packages/sms holds the provider-agnostic sender, an in-memory local adapter and a Twilio Programmable Messaging adapter (POST /2010-04-01/Accounts/{AccountSid}/Messages.json, success on 201). The API Worker reads:

Name Kind Notes
SMS_PROVIDER var twilio; local only with RUNTIME_ENV=local; unset turns SMS off
TWILIO_ACCOUNT_SID var AC…
TWILIO_MESSAGING_SERVICE_SID var MG…
TWILIO_AUTH_TOKEN secret Set by the owner; never a var, never logged

Incomplete or malformed settings leave SMS off. Provider responses are discarded unread.

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