AUTH4D-156 Custom domains for hosted login
A project can serve hosted login and its tenant issuer from its own hostname, such as
login.example.com. Each hostname is a Cloudflare for SaaS custom hostname on the auth zone. The
project adds DNS records, auth4.dev checks them, and once the hostname is active its issuer is
https://{hostname}/t/{tenantId}.
Schemas are in packages/contracts (CustomHostnameSchema, CreateCustomHostnameRequestSchema,
CustomHostnameListResponseSchema, CustomHostnameResponseSchema). The routes, the provider
clients and the per-request resolution are in apps/api/src/modules/custom-domains. Storage is
migration 0027_custom_hostnames.sql.
Management routes
All routes need a management token. The console reaches them through its BFF
(/api/management/…), which adds the token and checks the origin on writes. Every member can read;
owners and admins can add, check and remove hostnames. Viewer attempts are refused with
403 insufficient_role and audited as denied. Writes on a suspended project return
409 project_suspended.
| Method | Path | Scope | Result |
|---|---|---|---|
| GET | /management/tenants/{tenantId}/custom-domains |
tenant:read |
{ hostnames, canManage, available } |
| POST | /management/tenants/{tenantId}/custom-domains |
tenant:write |
201 { hostname } |
| POST | /management/tenants/{tenantId}/custom-domains/{hostnameId}/refresh |
tenant:write |
{ hostname } (body {}) |
| DELETE | /management/tenants/{tenantId}/custom-domains/{hostnameId} |
tenant:write |
{ hostname } (body {}) |
available is false when the deployment has no custom-hostname provider configured; adds and
status checks then return 503 custom_domains_unavailable.
Create body:
{ "hostname": "login.example.com" }
A hostname looks like this. dnsRecords lists the routing CNAME and, until the hostname is
active, the TXT records that prove ownership and let the certificate be issued:
{
"hostnameId": "chn_0f6c…",
"hostname": "login.example.com",
"status": "pending",
"certificateStatus": "pending",
"issuer": "https://login.example.com/t/ten_…",
"dnsRecords": [
{
"type": "CNAME",
"purpose": "routing",
"name": "login.example.com",
"value": "customers.auth4.dev"
},
{
"type": "TXT",
"purpose": "ownership",
"name": "_cf-custom-hostname.login.example.com",
"value": "5cc07c04-…"
},
{
"type": "TXT",
"purpose": "certificate",
"name": "_acme-challenge.login.example.com",
"value": "ca3-…"
}
],
"issue": null,
"lastCheckedAt": "2026-10-07T12:00:00.000Z",
"activatedAt": null,
"createdAt": "2026-10-07T12:00:00.000Z",
"updatedAt": "2026-10-07T12:00:00.000Z"
}
status is one of:
| Status | Meaning |
|---|---|
pending |
Waiting for DNS records or the certificate. The hostname does not serve. |
active |
Cloudflare verified the hostname and its certificate is live. It serves. |
failed |
Validation timed out, or the hostname no longer points at auth4.dev. |
blocked |
Cloudflare refuses the hostname. Contact support. |
issue is a fixed code (certificate_timed_out, hostname_blocked, hostname_moved or
verification_failed) or null. Provider error messages are never stored or returned.
refresh asks the provider for the current state and stores it. It is limited to 30 checks per
hostname per hour (429 rate_limited). A hostname the provider no longer knows becomes failed
with hostname_moved and stops serving; it is never re-adopted.
DELETE removes the provider’s custom hostname first, then the row; sign-in on the hostname stops
at once. If the provider cannot be reached the row stays and the call returns
503 temporarily_unavailable, so it can be retried.
Errors use the management error body. Codes specific to these routes:
| Status | Code | When |
|---|---|---|
| 400 | invalid_hostname |
The hostname breaks the rules below (details[0].issue says why). |
| 409 | hostname_taken |
Another project, or another Cloudflare zone, holds the hostname. |
| 409 | custom_domain_limit |
The project already has 3 custom domains. |
| 429 | rate_limited |
More than 20 hostnames added to the project in 24 hours. |
| 503 | custom_domains_unavailable |
The deployment has no custom-hostname provider. |
Hostname rules: a public subdomain of at least three labels (apex domains are not supported) in
lower-case ASCII; internationalised names are stored as punycode. Schemes, ports, paths, IP
addresses, special-use names (.localhost, .local, .internal, .test, .example, …),
auth4.dev, workers.dev, pages.dev and the deployment’s own origins are refused. issue
values in details are malformed, apex, ip_literal, reserved, special_use and
too_long. Each hostname belongs to at most one project. A hostname that has never been active 7
days after it was added no longer holds the name; see AUTH4D-339 (auth4d-339.md).
Audit events (target_type custom_hostname): management.custom_domain.created,
management.custom_domain.status_changed (with previous_status and status) and
management.custom_domain.deleted. Each carries the hostname and the actor’s role.
Serving on a custom hostname
The API Worker resolves the issuer and origins for every request:
- The configured auth origin answers as before.
- Any other hostname answers only while
custom_hostnameshas anactiverow for it. That row, never the Host header alone, names the one project the hostname serves. - Hostnames that are listed but not active answer
404. In staging and production, unlisted hostnames also answer404; Cloudflare-internal service-binding hosts keep the configured origin.
On an active custom hostname:
- The issuer is
https://{hostname}/t/{tenantId}. Discovery, JWKS, tokens and ID tokens carry it, and tokens are checked against it, so an app must use one issuer consistently. - Hosted-login URLs, device verification URLs, magic links and provider callback URLs (Discord, Steam, OpenID Connect, single sign-on) use the hostname.
- Cookies stay host-only (
__Host-prefix), so they are scoped to the hostname. - Same-origin checks on hosted-login writes expect
Origin: https://{hostname}. CORS admits the hostname and the deployment’sCLIENT_ORIGINS; browser applications keep their registered origins. The console and site origins have no standing. - The passkey relying party ID is the hostname. Passkeys made on the default auth origin do not work there, and the reverse.
Strict-Transport-Securityismax-age=31536000, withoutincludeSubDomains.- Only that project’s routes answer:
/t/{tenantId}/…for its own tenant,/oauth/…,/device/…,/,/login,/login/…,/assets/…,/healthand/ready. Atenant_idin the query, a form body or a JSON body that names another tenant gets404. The management API, management sign-in and billing webhooks never answer on a custom hostname.
Configuration
| Name | Kind | Value |
|---|---|---|
CUSTOM_HOSTNAME_PROVIDER |
var | cloudflare, or local in the local environment only. Unset turns it off. |
CUSTOM_HOSTNAME_CNAME_TARGET |
var | The fallback origin customers point their CNAME at. |
CLOUDFLARE_ZONE_ID |
var | The auth zone’s id (32 hex characters). |
CLOUDFLARE_CUSTOM_HOSTNAMES_TOKEN |
Worker secret | API token with SSL and Certificates Write on that zone. |
The local provider is deterministic: a new hostname is pending, and the first status check makes it
active. Hostnames whose first label is pending, blocked or failed stay in that state.
Source: docs/api-spec/auth4d-156.md