Auth
Account sign-in by emailed code or Google, for Simple Host account owners and the agents working for them. It ends in an API key. It is never for a site’s visitors: these routes answer only on the app’s own address (403 account_auth_unavailable elsewhere), and an API key never belongs in a page. A page signs its visitors in with the Visitor sign-in routes through auth.js.
GET /auth.md
Read the authentication reference
No authentication required.
Responses:
| Status | Description |
|---|---|
| 200 | Instance-aware sign-in documentation. Email-code steps are omitted when unavailable. |
POST /v1/auth
For Simple Host account owners and their agents, never for a site’s visitors. Answers only on the app’s own address: on a site’s address, a person’s address, a connected domain, or from a page’s origin it is 403 account_auth_unavailable, and no code is sent. A page signs its visitors in with POST /v1/sites/{sitename}/visitor/auth through auth.js instead. Emails a 6-digit code (and a magic link) to the address. No user row is created here — the account is created lazily on the first successful verify, so this can’t be used to enumerate users.
Request body:
| Name | Type | Required | Description |
|---|---|---|---|
| string | yes | ||
| nonce_hash | string | Browser pages only. base64url SHA-256 of a random nonce the page keeps. With it the email also carries a sign-in link, redeemable only together with that nonce (so only in that browser). Without it the email carries the code alone. |
Responses:
| Status | Description |
|---|---|
| 202 | Code sent |
| 400 | Invalid request. A saved-data write whose JSON holds a NUL character (\u0000), half of a surrogate pair or bytes that are not UTF-8 is code invalid_json. |
| 403 | account_auth_unavailable: account sign-in answers only on the app’s own address; a page’s visitors use visitor sign-in (auth.js SH.mount). Or email_signin_off (EMAIL_SIGNIN=off): sign in with the company account button instead. |
| 429 | Rate limited (code rate_limited). Saved-data reads allow 30 a second per address with a burst of 60 (SAVED_DATA_READ_PER_SEC, SAVED_DATA_READ_BURST). |
GET /v1/auth/oauth/providers
Public discovery for pages and the dashboard: auth.js reads it from a site to offer Google; it is not account sign-in and sets nothing. Google, GitHub and a company’s own identity provider (oidc). Returns the enabled provider names so pages never hardcode a provider. Empty when sign-in is not configured. Sign-in creates or links a users row; a hosted-page session still does not disclose an API key.
Responses:
| Status | Description |
|---|---|
| 200 | Enabled providers |
GET /v1/auth/oauth/{provider}
Account sign-in starts only on the app’s own address. On any other host (a site’s address, a connected domain) the same path answers 302 to GET /v1/visitor/oauth/{provider} on that host with the same return_to, so a hand-written link still reaches visitor sign-in and never an account key; from a page’s origin on the apex it is 403 account_auth_unavailable. A page offers Google to its visitors with auth.js SH.signIn(), which starts the SSO hop on the site’s host when configured, or GET /v1/visitor/oauth/{provider} on instances without an apex. Apex start. Query return_to is required and must be an absolute URL. Owner return addresses include the apex /v1/sso/hop with its state and browser nonce hash. The other shapes are the dashboard origin / (owner purpose — callback hands off via /?token=; success 302s to the provider with PKCE S256) or a site’s own address — a site host https://<site>.<handle>.simple-host.app/... (or, while that person’s certificate is pending, the person address https://<handle>.simple-host.app/<site>/...), a claimed name or a DNS-proven bound domain (site purpose). A site-purpose start is not begun here: it 302s to GET /v1/visitor/oauth/{provider} on that site’s own host, which binds the sign-in to the browser. Unknown or disabled provider is 404. No cookie is set here. The Legacy path-address (sites.<domain>/<handle>/<site>/) return_to URLs are rejected with 400 invalid return_to, as are apex URLs outside the dashboard and SSO hop. The caller cannot set purpose; it is derived from return_to.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| provider | path | string, one of google, github, oidc |
yes | |
| return_to | query | string | yes | Absolute URL of the page to return to after sign-in |
Responses:
| Status | Description |
|---|---|
| 302 | Redirect to the identity provider |
| 400 | Invalid or missing return_to |
| 403 | account_auth_unavailable: on the app’s address but from a page’s origin. |
| 404 | Unknown or disabled provider |
| 429 | Rate limited (code rate_limited). Saved-data reads allow 30 a second per address with a burst of 60 (SAVED_DATA_READ_PER_SEC, SAVED_DATA_READ_BURST). |
| 503 | oidc_unavailable: company sign-in’s identity provider could not be reached yet (lazy discovery); retry shortly. |
GET /v1/auth/oauth/{provider}/callback
Answers only on the app’s own address (403 account_auth_unavailable elsewhere). Provider redirect URI on the apex. Consumes the one-time state. Identifies via userinfo, then resolveUser (link or create a users row on a verified email; refuse missing/unverified email, and refuse an address that already has a PASSWORD_SIGNIN password account, a themed HTML page naming neither account). Site purpose 302s to https://<return-host>/v1/visitor/establish?once=…. Owner purpose 302s to {PUBLIC_BASE_URL}/?token=… for the dashboard to redeem via POST /v1/auth/verify. Neither redirect contains an API key. Failures are a short no-store HTML page on the apex with no Location and no cookie.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| provider | path | string, one of google, github, oidc |
yes | |
| code | query | string | ||
| state | query | string | ||
| error | query | string |
Responses:
| Status | Description |
|---|---|
| 302 | Site purpose: redirect to /v1/visitor/establish on the stored host. Owner purpose: redirect to /?token= on PUBLIC_BASE_URL. |
| 400 | Bad, replayed, or expired state, or provider error |
| 403 | account_auth_unavailable (JSON): not on the app’s own address. For oidc only, also a business-rule refusal (unverified email, domain not on ALLOWED_EMAIL_DOMAINS, Google hd not allowed): a themed no-store HTML page naming the reason in plain words, no internals. |
| 404 | Unknown or disabled provider |
| 429 | Rate limited (code rate_limited). Saved-data reads allow 30 a second per address with a burst of 60 (SAVED_DATA_READ_PER_SEC, SAVED_DATA_READ_BURST). |
| 502 | Token or userinfo failure |
POST /v1/auth/password
Available only when PASSWORD_SIGNIN=on. Passwords are 8–256 characters. Temporary passwords require a different new_password at sign-in.
No authentication required.
Request body:
| Name | Type | Required | Description |
|---|---|---|---|
| login | string | yes | |
| name | string | ||
| new_password | string | ||
| password | string | yes | |
| sso_state | string |
Responses:
| Status | Description |
|---|---|
| 200 | Authenticated |
| 400 | Invalid request. A saved-data write whose JSON holds a NUL character (\u0000), half of a surrogate pair or bytes that are not UTF-8 is code invalid_json. |
| 401 | Wrong username or password (bad_credentials), including unknown and passwordless accounts. |
| 403 | password_change_required: current password verified; resubmit with a different new_password. Or account_suspended. |
| 404 | not found: PASSWORD_SIGNIN is off. |
POST /v1/auth/password/signup
Available only when PASSWORD_SIGNIN=on. Passwords are 8–256 characters. Temporary passwords require a different new_password at sign-in. Username accepts any value, an email is suggested but not required; it is trimmed and lower-cased, 1–254 characters, no control characters.
No authentication required.
Request body:
| Name | Type | Required | Description |
|---|---|---|---|
| password | string | yes | |
| username | string | yes |
Responses:
| Status | Description |
|---|---|
| 200 | Authenticated |
| 400 | invalid_username or invalid_password |
| 403 | signup_unavailable_region |
| 404 | not found: PASSWORD_SIGNIN off or PASSWORD_SELF_SIGNUP off |
| 409 | username_taken |
| 429 | rate_limited; Retry-After header |
POST /v1/auth/verify
Answers only on the app’s own address (403 account_auth_unavailable elsewhere), so no hosted page can mint an account key. The apex visitor sign-in card also uses this endpoint, keeping the account key on the apex and returning only a site cookie through the SSO hop. After success, browser sign-in calls POST /v1/sso/session with the returned key to establish the apex SSO cookie. Exchange {email, code} (CLI/agent) or a magic-link {token} (browser) for an API key. The first successful email-code verify creates the account. Dashboard Google also redeems through this endpoint (/?token=&cn=). A link token is redeemed only with the nonce whose hash it was issued with, so a sign-in link works only in the browser that asked for it. Visitor-issued codes are not accepted here. The code expires in 15 minutes and allows 3 attempts.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| from | query | string | Optional same-visit attribution label (letters, digits, hyphens); recorded only for a new browser/connector account, never a permission or cookie. | |
| utm_source | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. | |
| utm_medium | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. | |
| utm_campaign | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. | |
| utm_term | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. | |
| utm_content | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. | |
| referrer | query | string | Optional same-visit attribution; UTM values are bounded plain text, referrer is an outside hostname only. |
Request body:
| Name | Type | Required | Description |
|---|---|---|---|
| choose_handle | boolean | When this sign-in would create a new account: create nothing yet and answer 409 with code choose_handle and a suggested_handle, without using up the code. Ask the person which address they want, then verify again with the same code and handle. Ignored for an existing account. | |
| code | string | ||
| string | |||
| handle | string | The address (<handle>.simple-host.app) a NEW account takes; ignored for an existing account. Refused as 400 invalid_handle, 409 handle_reserved or 409 handle_taken without using up the code, so another can be tried. It can be changed later (PATCH /v1/me), not more than once in 30 days after publishing. Without handle or choose_handle a new account is given one. | |
| name | string | Optional label for the key this sign-in issues, shown in the Keys list (GET /v1/me/keys). Defaults to “dashboard sign-in” for a link or Google, “agent sign-in” for a typed code. | |
| nonce | string | required with token — the nonce whose hash was sent when the link was requested | |
| sso_state | string | Optional server-issued hop state for visitor signup attribution | |
| token | string | magic-link token (alternative to email+code) |
Responses:
| Status | Description |
|---|---|
| 200 | Authenticated |
| 400 | Invalid request. A saved-data write whose JSON holds a NUL character (\u0000), half of a surrogate pair or bytes that are not UTF-8 is code invalid_json. |
| 401 | Invalid code. code stays invalid_code. reason is code_wrong, code_used_up, code_expired, or link_other_browser; tries_left is set on code_wrong. |
| 403 | account_auth_unavailable: account sign-in answers only on the app’s own address; a page’s visitors use visitor sign-in (auth.js SH.mount). Or email_signin_off (EMAIL_SIGNIN=off), for the email+code shape only — a magic-link token (from the company sign-in or Google flow) still redeems normally. |
| 409 | code choose_handle (a new account: choose its address first; carries suggested_handle and address), handle_taken / handle_reserved (the handle sent cannot be had; error names the address), or password_account_exists (this address already has a password account from PASSWORD_SIGNIN; the code is spent, sign in with the password instead). The code is not used up except for password_account_exists. |
GET /v1/handles/check
Whether <handle>.simple-host.app can be taken as an account’s address: at sign-up (POST /v1/auth/verify with handle) or as a change (PATCH /v1/me). Handles, old handles that still redirect, free <name>.simple-host.app addresses claimed by sites, other accounts’ live site names (an unclaimed <name>.simple-host.app still redirects to that site) and reserved names share one namespace. With X-API-Key the account’s own handle counts as available. Rate limited per address (RATE_LIMIT_HANDLE_CHECK); no CORS, and a cross-site browser request gets 403. A claim still decides.
Parameters:
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| handle | query | string | yes |
Responses:
| Status | Description |
|---|---|
| 200 | The verdict |
| 403 | Cross-site browser request |
| 429 | Too many checks from this address |