# Security model

> CustomDomain™ security model: tenant isolation, credentials and roles, value-checked DNS verification, provider credentials and unauthenticated surfaces.

Source: https://docs.customdomain.ai/docs/security/overview

## 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](/docs/authentication/overview)):

* **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_` or `cd_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](/docs/authentication/overview)), 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 a `viewer`'s key cannot mutate anything, and a demotion or
  removal takes effect immediately. See [Member roles](#roles--permissions).
* 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](#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](/docs/billing/plans-and-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:start` and `:complete`.
* **Managed DNS:** `POST /v1/connections/{id}/managed-dns` and the record
  add and delete routes under it.
* **Share links:** `POST /v1/sharing/connect`.
* **SSL:** `ssl:provision`, `ssl:renew`, `ssl:import`, and
  `DELETE /v1/connections/{id}/ssl`.
* **Power:** `POST /v1/connections/{id}/power`, `power:repower`, and
  `DELETE /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 `admin` or `owner`. 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 `admin` or
  `owner` (`agent:manage`).
* **Billing** (checkout, portal) requires `admin` or `owner` (`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, `member` or above. A `viewer` is 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 (`403` for another `member_id`).

Unknown or vanished roles are **fail-closed**: denied everything, never
escalated. See [Authentication](/docs/authentication/overview#member-roles).

## 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](#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 &#x2A;*PKCE (S256)**
  and an &#x2A;*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}/apply` **discards 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](/docs/connect-flow/provider-accounts) 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](/docs/connect-flow/managed-connections#managed-sign-in-grants)
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](/docs/guides/secure-sign-in#lasting-api-keys) 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](/docs/connect-flow/provider-accounts#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](#encryption-keys-self-hosted));
* **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](/docs/guides/nameserver-migration), 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](/docs/connect-flow/provider-accounts#audit-log));
* 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](/docs/dns/provider-setup#remembering-a-key-optional).

## Secrets handling

* API keys and application `client_secret`s 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 its
  `client_secret` sealed (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-SHA256` over 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](/docs/concepts/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. &#x2A;*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-use
  `state`, 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](/docs/mcp/overview#authentication)).

## 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.
