Security model
CustomDomain™ security model: tenant isolation, credentials and roles, value-checked DNS verification, provider credentials and 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 (
cd_live_orcd_test_plus 32 hex characters) are stored as SHA-256 hashes and shown once at creation (the workspace's default key is the exception: the console keeps a sealed copy that an owner can view, see API keys), and revocation takes effect on the next request. Their authority depends on how the key was minted: a plain tenant key (the one issued at provisioning, or minted by an operator) acts with owner authority; the workspace's default key acts with admin authority and mints no keys, since anyone who once viewed it may still hold it; and a key minted for an invited member is bound to that member and resolves to their current role on every request, so aviewer's key cannot mutate anything, and a demotion or removal takes effect immediately. See Member roles. - 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, and is enforced at the control-plane; see
Roles & permissions. A member's role is resolved from the
credential itself, server-side, on every request; nothing trusts a
console-supplied role.
Roles & permissions
Each member holds one of four roles on a strict ladder
(viewer < member < admin < owner), which maps to a fixed set of
permissions. The authoritative matrix is the same one served (unauthenticated,
as static reference) at GET /v1/rbac:
| Permission | viewer | member | admin | owner | Grants |
|---|---|---|---|---|---|
stats:read | ✅ | ✅ | ✅ | ✅ | View stats / usage |
audit:read | ✅ | ✅ | ✅ | ✅ | Read the audit log |
app:read | ✅ | ✅ | ✅ | ✅ | View app config |
member:read | ✅ | ✅ | ✅ | ✅ | List members |
app:write | ✅ | ✅ | ✅ | Mutate app settings, rotate secrets, and every mutating connection route (see below) | |
member:manage | ✅ | ✅ | Invite / remove members, change roles | ||
keys:manage | ✅ | ✅ | Mint / revoke API keys | ||
identity:manage | ✅ | ✅ | White-label branding. (Reserved for SSO / SCIM, which are not built; see Plans & quotas.) | ||
agent:manage | ✅ | ✅ | Approve, list and revoke delegated-agent grants (a grant can carry domains:purchase with a spend cap against the tenant's saved card, so it is gated at the same tier as billing) | ||
billing:manage | ✅ | ✅ | Billing: the subscription, plan changes, card and invoices (Stripe Billing Portal), promotion codes. Owners and admins since 2026-09-29; members and viewers get 403 on every billing route | ||
org:delete | ✅ | Delete / transfer the org |
app:write is the permission behind the whole mutating connection family,
not just application settings. It gates these routes:
- Connections:
POST /v1/connections,POST /v1/connections:bulk,POST /v1/connections/{id}/apply,POST /v1/connections/{id}:reapply,POST /v1/connections/{id}:recheck,POST /v1/connections/{id}:disconnect,PUT /v1/connections/{id}/records,DELETE /v1/connections/{id},DELETE /v1/connections/{id}/grant. - Provider rails:
POST /v1/connections/{id}/oauth:start,POST /v1/connections/{id}/domainconnect:start,POST /v1/connections/{id}/route53-role:startand:complete. - Managed DNS:
POST /v1/connections/{id}/managed-dnsand the record add and delete routes under it. - Share links:
POST /v1/sharing/connect. - SSL:
ssl:provision,ssl:renew,ssl:import, andDELETE /v1/connections/{id}/ssl. - Power:
POST /v1/connections/{id}/power,power:repower, andDELETE /v1/connections/{id}/power. - Webhooks:
POST /v1/webhooks,DELETE /v1/webhooks/{id}.
An API key reaches these for a connection of any application in its tenant, whichever application the key belongs to; the member's role is what decides.
So a viewer's member-scoped key is refused with 403 on all of them: it
cannot delete a live connection, deprovision a certificate, or register a
webhook endpoint to obtain its signing secret. Reads on the same resources are
unaffected. The browser-facing widget token is unchanged too: it carries no
member at all, so it is not role-gated here; it stays constrained by its
application and (optional) domain binding, which is what lets a customer's own
end users complete a connect flow.
Enforcement is at the control-plane, not just the console. Every sensitive
mutation re-checks the acting member's role server-side before acting, so a
member or viewer who calls the API directly is refused with 403; the
console's role gates are defense-in-depth, not the security boundary. In
particular:
- Minting or revoking an API key and inviting / removing a member or
changing a role require
adminorowner. So does deleting an application (DELETE /v1/applications/{id}), because it revokes the application's keys. - Approving, listing or revoking a delegated-agent grant requires
adminorowner(agent:manage). - Billing (checkout, portal) requires
adminorowner(billing:manage). A widget token is refused outright on tenant-level mutations like these: it ships in every embed, so it is never treated as tenant authority. - Mutating a connection, its SSL, its Power config, or a webhook requires
app:write, that is,memberor above. Avieweris refused. - A caller can never grant a role above their own, and only an owner can create, promote-to, or demote an owner. The org's last owner cannot be demoted or removed.
- The workspace's default key acts as an admin, never as an owner: it is
refused (
403) when it would make, unmake or remove an owner or delete the workspace, and it cannot mint API keys at all. Only an owner can view or rotate it in the console. - Approving an agent with a member's own key records that member as the
approver and no one else (
403for anothermember_id).
Unknown or vanished roles are fail-closed: denied everything, never escalated. See Authentication.
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
The automatic rails use a customer's DNS-provider credential once and discard it, unless it is kept as a provider account or a managed sign-in grant (see Kept keys and sign-ins):
- OAuth (Rail C) exchanges a code for an access token, writes the records,
and drops the token unless the sign-in is kept. 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 credential you pass on a single write.
POST /v1/connections/{id}/applydiscards it after that write; the console's remember box, which starts ticked, keeps it as a provider account instead (see below). The console collects multi-part credentials as separate labeled fields and joins them only for that request. - One-click setup (Rail A) uses no server-to-server credential; its apply
URLs are RS256-signed from
DC_SIGNING_KEY_FILE.
Kept keys and sign-ins
A Rail B key is kept as a continuous
provider account when you connect it
with POST /v1/provider-accounts, or when the console's Remember this key for
my ... account box stays ticked (it starts ticked and the user can untick it).
A sign-in is kept as a
managed sign-in grant
only at a provider the deployment lists in OAUTH_MANAGED_PROVIDERS. The hosted
service lists Cloudflare, DigitalOcean, DNSimple, Linode, Netlify, Porkbun,
Vercel and WordPress.com, and keeps a sign-in there unless the user unticks
Keep this sign-in or the call sends "access": "one_time"; the widget tells
the customer the permission is kept. A
secure sign-in keeps the lasting
API key it made at the provider (never the sign-in itself), as a provider account
that serves the application whose domain it was made for and the ones an admin
shares it with, after one read only call proved it works; its rights say what
it can do beyond DNS. Account
passwords and tokens that expire within hours are never kept: the control plane
refuses to keep them
(credentials that are used once).
Whatever is kept:
- is sealed at rest with AES-256-GCM under the control plane's
GRANT_ENCRYPTION_KEY. The control plane can also write the newer v2 format, which binds each value to its workspace and record (a copy of it placed in another workspace's record does not open) and names the key it was sealed under, so the key can be rotated (Encryption keys); - never leaves the server: the control plane uses it itself and never returns it; the browser sees only details such as the provider, a label, the domains and the last use;
- is used only to write, put back and remove the records of the domains it covers and, for Domain maintenance, to change their nameservers when the account is at the domain's registrar;
- leaves a trail: the workspace's audit log records who kept, changed, widened, moved, replaced or removed it, and each nameserver move that used it (provider account events);
- is revocable at any time on the console's Connected accounts page (Revoke on an account, Stop keeping on a kept sign-in, Remove on a key made at sign-in), which deletes it and leaves the domains connected. A key made at sign-in also lives at the provider until it is revoked there.
A deployment without GRANT_ENCRYPTION_KEY keeps no provider accounts or
sign-ins. Its console keeps a remembered key itself instead, sealed with
AES-256-GCM under WEB_SECRET_KEY (per-blob HKDF-SHA256 key) for one workspace
and provider, and only to connect more domains. A sign-in that is not kept and
one-click setup (which uses no server token) store nothing. See
Provider setup.
Secrets handling
- API keys and application
client_secrets are stored by the control plane only as SHA-256 hashes; the plaintext is shown once and never returned again. The workspace's default key is one exception: the console keeps a sealed copy that an owner can view. An application created in the console is the other: the console keeps itsclient_secretsealed (AES-256-GCM) so it can mint widget tokens for you, and never displays it. An application created over the API returns the secret once. - Widget JWTs are signed per application, using a dedicated server-side signing
key (
HMAC-SHA256over the application's id), never the application's own secret hash, so a token is only valid for its own application and a leaked application record can't be used to forge one. - 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.
Encryption keys (self-hosted)
The control plane keeps five kinds of secret encrypted: provider keys and sign-ins a workspace kept, sign-in grants, MCP sign-in client secrets, webhook signing secrets and the SMTP passwords of an application's own mail sender. Each is sealed with AES-256-GCM in one of two formats:
- v1 is one fixed key and nothing else. A value opens wherever it is stored, so a copy of one workspace's value placed in another workspace's record would open there.
- v2 binds the value to what it belongs to (the kind of secret, the
workspace and the record) and starts with the id of the key that sealed it
(
v2:<key id>:...). A value copied into another workspace's record, into another record or into another column fails to open and is never used. The key id is a short label derived from the key, not the key.
| Variable | What it does |
|---|---|
GRANT_ENCRYPTION_KEY | The v1 key, base64 of 32 random bytes. It works exactly as before and opens every v1 value. |
GRANT_ENCRYPTION_KEYS | Comma separated keys for v2, the current one first. Every key opens a value; the first seals new ones. The v1 key is always a v2 key too: when the list does not name it, it follows the list and only opens, so a value sealed under it still opens. Unset, the v1 key is the one v2 key. |
GRANT_ENCRYPTION_WRITE | v1 or v2: the format new and changed values are written in. The default is v1 while GRANT_ENCRYPTION_KEY is set, so an older release still reads every row. With GRANT_ENCRYPTION_KEYS and no GRANT_ENCRYPTION_KEY there is no default: v1 needs the v1 key, so you must set this to v2, and the control plane refuses to start until you do. |
GRANT_ENCRYPTION_READ_V1 | on (the default) or off. Off refuses a v1 value, such as a pasted or restored one. |
WEBHOOK_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEYS | The same, for webhook signing secrets and SMTP passwords, when they have keys of their own. Unset, they use the grant keys. |
To rotate a key, add the new key as the second entry of GRANT_ENCRYPTION_KEYS
and deploy (every instance can then read it), set GRANT_ENCRYPTION_WRITE=v2,
run credctl rewrap --to v2 inside the control plane container (it moves every
stored secret, checks each one opens, and can be run again if it stops), then
make the new key the first entry and run credctl rewrap --to v2 again.
credctl status counts stored secrets by format and key id, and credctl verify
opens every one. Retire the old key only when credctl status shows nothing
under it and no backup you may still restore holds anything sealed under it. A
list that is set but unusable stops the control plane at start. So does a valid
list beside a GRANT_ENCRYPTION_KEY that is set but unusable (it would leave
sign-ins off while webhook secrets are written in v2), and so does a list with no
GRANT_ENCRYPTION_KEY and no GRANT_ENCRYPTION_WRITE. The same rules apply to
the keys of webhook secrets and to credctl. An error or a log line names a
variable or a key id, never a key. At start the control plane also logs, and
exposes as credential_rows, how many stored secrets are in each format and
under each key id (a count taken once at start; credctl status is the live one).
Because the v1 key stays a v2 key after the list, an old key that is also
GRANT_ENCRYPTION_KEY is retired only when that variable is unset too.
Once any v2 value exists, never run a release older than the one that added v2:
it cannot read v2 values. To go back, set GRANT_ENCRYPTION_WRITE=v1, run
credctl rewrap --to v1, and then change releases.
A v2 value is bound to its workspace, so a record that is moved to another
workspace stops opening. Nothing in the product moves one; if you move records by
hand, run credctl rewrap --to v1 first (with GRANT_ENCRYPTION_WRITE=v1), move
them, then credctl rewrap --to v2.
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-usestate, not a bearer token.
Rate limits
Requests are limited per client IP address: about 10 requests per second
steady, with a burst of 30. Over the limit the API returns 429 with the code
rate_limited; wait and retry. The credential-minting routes
(POST /v1/tokens and the agent OAuth token and registration routes) fail
closed if the limiter store is unavailable, rather than letting requests through
unthrottled. The edge host-authorization gate (/internal/ask) is exempt. Agent
client registration also has its own hourly budget per IP. The MCP server limits
its own POST /token and refused CLIENT_ID:CLIENT_SECRET pairs per IP as well
(see MCP server).
CORS
The API is bearer-authenticated with no ambient cookies, so it reflects a
wildcard origin (Access-Control-Allow-Origin: *), which is safe because there are no
cookies to abuse. Per-application embed control lives at the widget layer.
Response headers
api.customdomain.ai and mcp.customdomain.ai send these on every answer,
including the ones the proxy in front of them gives itself:
Strict-Transport-Security: max-age=63072000; includeSubDomains(two years, not preloaded), so a browser reaches them only over HTTPS;X-Content-Type-Options: nosniff, so a JSON or text answer is never run as a script;Referrer-Policy: strict-origin-when-cross-origin, unless the answer sets its own.
The pages a DNS provider sends the browser back to (the sign-in callback
GET /v1/connect/oauth/callback, and the one-click callback and return page)
refuse to be framed (X-Frame-Options: DENY and
Content-Security-Policy: frame-ancestors 'none'), so another site cannot load
one in a hidden frame; the page that reports the outcome to your window sends
Referrer-Policy: no-referrer. Reporting to the window that opened the popup is
unaffected.
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
Report suspected vulnerabilities privately to the maintainers before public disclosure.
Authentication and API keys
The four credential types (API keys, widget JWTs, agent tokens and operator secrets), their blast radius, member roles, and the unauthenticated surfaces.
CustomDomainBot
What CustomDomainBot requests from a domain when someone enters it in CustomDomain™, what data we keep, and how to ask us to stop checking your domain.