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.