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