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_hostnames has an active row 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 answer 404; 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’s CLIENT_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-Security is max-age=31536000, without includeSubDomains.
  • Only that project’s routes answer: /t/{tenantId}/… for its own tenant, /oauth/…, /device/…, /, /login, /login/…, /assets/…, /health and /ready. A tenant_id in the query, a form body or a JSON body that names another tenant gets 404. 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