Custom Domain
Security

Overview

Tenancy, credential model, value-checked DNS, one-time provider tokens, and the unauthenticated surfaces.

Tenancy & isolation

Every object belongs to a tenant. Tenant-scoped endpoints filter by the caller's credential, and all /applications/{id}/* mutations verify the application belongs to the caller before acting. Cross-tenant access returns 404 — never another tenant's data, and never a 403 that would confirm an object exists.

Credential model

Three credential types with escalating authority (see Authentication):

  • Widget JWTs are short-lived, scoped to one application (and optionally one domain), and accepted only on the browser-facing Connect endpoints. This is the only credential that reaches the browser.
  • API keys carry full tenant authority; they are stored as hashes and shown once at creation, and revocation takes effect on the next request.
  • The management key and provision secret gate operator/signup routes; in production the control-plane refuses to start with provisioning left open.

Human authorization is by member role (owner/admin/member/viewer), not per-key scopes — the matrix is served at GET /v1/rbac.

DNS verification is value-checked

Propagation and drift checks require the exact desired value: a CNAME must resolve to the expected target and an A/AAAA must contain the exact address. "The name resolves to something" is never accepted, because the record set is controlled by whoever is being verified.

Provider credentials are never stored

The automatic rails use a customer's DNS-provider credential exactly once and discard it:

  • OAuth (Rail C) exchanges a code for a one-time access token, writes the records, and drops the token. The flow uses PKCE (S256) and an HMAC-signed, single-use state; the callback only postMessages its outcome to a return origin on the server's allowlist (OAUTH_ALLOWED_RETURN_ORIGINS).
  • API token (Rail B) uses the scoped token you pass on a single write; it is never persisted.
  • Domain Connect (Rail A) uses no server-to-server credential; its apply URLs are RS256-signed from DC_SIGNING_KEY_FILE.

Secrets handling

  • API keys and application client_secrets are stored only as hashes; the plaintext is shown once and never returned again.
  • Widget JWTs are signed per application (HMAC over the app's secret hash), so a token is only valid for its own application.
  • The console never exposes the tenant's control-plane API key to the browser: it is sealed at rest (AES-256-GCM under WEB_SECRET_KEY) and opened server-side per request behind a proxy — see Architecture.
  • Internal errors return a generic title; the specifics (including any driver errors) are logged server-side under a request id, never returned.

Unauthenticated surfaces

  • GET /healthz — liveness + DB readiness; no secrets, no tenant data.
  • POST /internal/ask — the edge host-authorization gate, authenticated by a shared secret (EDGE_ASK_SECRET) and meant to be network-isolated to the edge. It answers only { approved } and never reveals the tenant.
  • GET /v1/connect/oauth/callback — authenticated by its signed single-use state, not a bearer token.

CORS

The API is bearer-authenticated with no ambient cookies, so it reflects a wildcard origin (Access-Control-Allow-Origin: *) — safe because there are no cookies to abuse. Per-application embed control lives at the widget layer.

Exactly-once side effects

The live transition (and its connection.live webhook + usage metering) is guarded by a compare-and-swap so the API path and the background poller can't double-fire.

Reporting

This is an open-source project; report suspected vulnerabilities privately to the maintainers before public disclosure.

On this page