Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Connect a customer's Cloudflare DNS

Let customers connect Cloudflare with OAuth or an API token so your SaaS can add their DNS records. Pairs with Cloudflare for SaaS hostnames.

DomainKit writes DNS records to a customer’s own Cloudflare account, after they connect it and approve the exact records. It offers two connect methods on one definition, API tokens and OAuth, and both keep the credential on your server. If you serve customer hostnames with Cloudflare for SaaS, DomainKit writes the CNAME and validation records that service asks your customer to add. See Cloudflare for SaaS and DomainKit.

Register the provider

/** With no options Cloudflare offers API tokens only, and nothing needs registering. */
export const tokensOnly = Cloudflare.provider();
/**
 * Adding OAuth adds one method to the same definition. Scope ids come from the OAuth client you
 * registered with Cloudflare; the default set is `zone.read`, `dns.read`, `dns.write`,
 * `offline_access`.
 */
export const withOAuth = Cloudflare.provider({
  oauth: {
    clientId: Config.String("CF_CLIENT_ID"),
    clientSecret: Config.Redacted("CF_CLIENT_SECRET"),
  },
});
/** Public clients use PKCE and do not carry a client secret. */
export const withPublicOAuth = Cloudflare.provider({
  oauth: {
    clientId: Config.String("CF_CLIENT_ID"),
    clientAuth: "none",
  },
});

clientId takes a value or a Config. Confidential clients also require clientSecret, which can stay in the config provider your app already uses. Public clients set clientAuth: "none" and omit the secret; PKCE remains required for that flow. scopes defaults to zone.read, dns.read, dns.write, and offline_access; they are the scope ids assigned to your OAuth client, not something DomainKit invents. Confidential clientAuth defaults to client_secret_basic.

The OAuth endpoints DomainKit uses are dash.cloudflare.com/oauth2/auth, /token, and /revoke. Register the callback URL your routes are mounted at, ending in /callback/cloudflare.

For development against an emulator, issuer moves those three endpoints off https://dash.cloudflare.com and baseUrl moves the REST API off Cloudflare’s.

Connecting with a token

/**
 * A user token spans every account it can reach. An account-owned token is pinned, so the customer
 * supplies the account id the token belongs to; the connect form renders both fields from
 * `Cloudflare.TokenFields`.
 */
export const connectAccountToken = Connect.start({
  provider: "cloudflare",
  method: Connect.Method.token({ token: "cf_api_token", accountId: "acct_1" }),
  domain: "app.example.com",
});

The connect form renders from the method descriptor, which carries two fields:

Field Required Secret Means
token yes yes The API token
accountId no no The account an account-owned token belongs to

A user token spans every account it can reach. DomainKit verifies it, discovers the account from the zones it can see, and records tokenKind: "user", so the same connection can serve domains in more than one account.

An account-owned token is pinned to one account and cannot be verified without naming it. Supply accountId and DomainKit verifies against that account and records tokenKind: "account".

Point the customer at Cloudflare’s token page; the descriptor carries the URL, so a connect form links it without hard-coding anything.

Connecting with OAuth

export const connectOAuth = Effect.map(
  Connect.start({
    provider: "cloudflare",
    method: Connect.Method.oauth({ returnTo: "/settings/domains" }),
    domain: "app.example.com",
  }),
  (started) => (started._tag === "Redirect" ? started.authorizationUrl : null),
);

Connect.start answers a Redirect. Send the customer there; Cloudflare drives the browser back to /callback/cloudflare, where DomainKit exchanges the code, stores the sealed credential, and redirects to the returnTo the flow began with.

The credential packs the access and refresh tokens together, so a refresh is invisible to the host. DomainKit refreshes ten minutes before expiry, single-flighted, and revokes at Cloudflare when the customer disconnects.

Derive registration requirements

/** Requirements for the provider console; this does not resolve credentials or verify a grant. */
export const registrationSettings = Server.registrationSettings({
  providers: [withOAuth],
  callbackBaseUrl: "https://app.acme.dev/api/domainkit",
});

Reuse callbackBaseUrl in your mounted server options. It includes your public origin and mount path; the output appends /callback/cloudflare. Derivation reads no credentials and makes no provider calls. It describes setup requirements, not console readiness or granted permissions. See the server reference for the output schema and failures.

The OAuth output includes the effective clientAuth, pkce: "S256" for public and confidential clients, and your configured scopes. Empty client ids or confidential secrets, unsupported runtime auth modes, malformed scope tokens, and malformed endpoint URLs fail InvalidInput on the relevant configuration field before redirect. Config resolution failures name the field without its source error or value. Endpoint URLs support emulator paths and split browser/server hosts, and must be HTTP(S) without credentials, query, or fragment. Custom scopes remain your choice.

Capabilities

DomainKit needs dns:read and dns:write. Cloudflare’s token verification does not enumerate DNS permissions, so a token missing dns.write verifies here and fails at the first write with Forbidden.

A token Cloudflare rejects while connecting fails Unauthenticated with Cloudflare’s own message, whether Cloudflare answers HTTP 400 with code 6003 or 6111 (malformed token), 401, or 403 from the verify endpoint. The server answers 401, so a connect form offers a retry on the same field. A 403 while listing zones is a missing zone.read permission and stays Forbidden.

Naming the account

Connecting reads the account from the zones the credential sees: a credential inside one account carries that account’s Cloudflare name as the connection’s label, and one spanning several names none. A token pinned to an account is named from that account’s zones, so a pinned token that reaches no zone connects unnamed rather than failing.

Zones and targets

listTargets lists the zones the credential reaches, each with its Cloudflare zone id and the nameservers Cloudflare reports. Those nameservers are what Connect.discover matches against a domain’s authoritative delegation.

The definition declares the ns.cloudflare.com nameserver suffix, so Connect.discover names Cloudflare as the host of a domain delegated there before the customer connects it.

A target without a zone id cannot hold records, and DomainKit fails Unsupported rather than guessing.

Records

Cloudflare’s DNS API is the record surface: A, AAAA, CAA, CNAME, MX, NS, SRV, and TXT. A record type DomainKit does not model is kept as an opaque observation, so planning sees it, refuses to overwrite it, and reports a conflict.

Adding a provider of your own is the write a provider guide.

Callback recovery

Host-started flows can opt in to a sanitized destination policy on the mounted server:

/** Connect through the mounted Server callback; re-inspect before planning DNS. */
export const callbackOptions: Server.Options = {
  callback: (outcome) =>
    Effect.succeed(
      outcome._tag === "Connected"
        ? undefined
        : `/settings/domains?connection=${outcome._tag === "Cancelled" ? "cancelled" : outcome.recovery}`,
    ),
};

Pass these options to Server.layer or Server.toWebHandler. Connected means a durable account connection, not DNS readiness; re-inspect and select the zone before planning. The policy receives only live, authenticated outcomes and validated return destinations. Cancellation recognizes only access_denied; other callback errors are Failed. Cancellation and failure retain the flow’s expiry and spending behavior. inspect can mean partial persistence and does not authorize replaying a one-time grant. See callback destinations for identity, same-origin, and failure boundaries.

Last updated on

Was this page helpful?