Skip to main content
Version: 0.11

DNS-01 providers

Overview

DNS-01 is the ACME challenge type that proves domain control by publishing a _acme-challenge TXT record. It is the only challenge that can issue wildcard certificates, and it works for hosts that are not reachable from the internet.

TokenTimer's agent ships native solvers for 11 providers with zero npm dependencies: HTTP providers use the runtime's own fetch, request signing (Route 53 SigV4, the Google Cloud JWT, the OVH signature, Exoscale EXO2-HMAC-SHA256) is computed with Node's crypto, and RFC 2136 speaks the DNS wire protocol directly with a TSIG HMAC.

Your provider is not in this list

This list is the full set the agent can drive directly, not a limitation of certbot or acme.sh themselves (both support many more providers through their own plugins). The agent does not shell out to third-party certbot/acme.sh DNS plugins: doing so would mean provider credentials, propagation waits, and error handling for that provider bypassing the agent's own policy enforcement and redaction. If your provider is not one of the 11 above, run certbot or acme.sh yourself with its own plugin for that provider, entirely outside the agent, and report progress back with a machine API token instead. See Connecting external executors.

Credentials never leave the host

DNS provider credentials are read from 0600-mode files on the agent host, referenced by path from the agent's config.json. They are never passed on the command line (where other local processes could read them from the process table), never sent to TokenTimer, and never written to logs. The control plane stores only provider names for allowlisting and routing. This is the same zero-custody rule that applies to private keys.

How a challenge runs

certbot and acme.sh do not talk to your DNS provider directly. They call back into the agent's DNS hook, which:

  1. resolves which provider owns the challenge zone,
  2. publishes the TXT record through the provider's API,
  3. waits for propagation by polling resolvers until the record is visible,
  4. lets the ACME client trigger validation,
  5. removes the record afterwards.

Step 3 is what makes DNS-01 reliable in practice. Skipping it is the single most common cause of flaky DNS-01 setups, because the CA often queries before a freshly written record has propagated.

Concurrent challenges on the same name (an apex plus its wildcard, for example) are handled without clobbering each other: providers that replace whole record sets read the existing values first and write the union.

Zone resolution

The challenge hostname is not assumed to be the zone. With a single configured provider and no explicit mapping, the agent discovers the zone via DNS NS lookup or the provider's own zone list. On hosts serving multiple providers, zoneProviderMap routes zones explicitly, and the longest matching zone wins on a dot boundary (so internal.example.net does not accidentally match example.net):

{
"dnsProviders": {
"cloudflare": { "credentialsFile": "/etc/tokentimer-agent/dns/cloudflare.json" },
"rfc2136": { "credentialsFile": "/etc/tokentimer-agent/dns/rfc2136.json" },
"zoneProviderMap": {
"example.com": "cloudflare",
"internal.example.net": "rfc2136"
}
}
}

Propagation tuning

The optional top-level dnsPropagation block controls the wait after publishing and after cleanup:

{
"dnsPropagation": {
"timeoutMs": 120000,
"intervalMs": 2000,
"resolvers": ["1.1.1.1", "8.8.8.8"],
"checkAuthoritative": true,
"verificationMode": "all",
"quorumCount": null
}
}
FieldDefaultMeaning
timeoutMs120000Give up waiting after this long
intervalMs2000Poll interval between checks
resolvers[] (none)Extra recursive resolvers to query, on top of the authoritative check
checkAuthoritativetrueQuery the zone's authoritative nameservers
verificationModeallall requires every check to agree; quorum requires quorumCount of them
quorumCountnullRequired positive integer in quorum mode

By default no public resolvers are queried: the wait relies on the authoritative nameservers, which is the earliest reliable signal. Add resolvers when you also want to confirm that recursive resolvers have picked the record up.

Use quorum when one of several configured resolvers is consistently slow or flaky and all makes issuance unreliable. Use all (the default) when you want the strictest guarantee that the record is visible everywhere before validation.

A host without IPv6 egress can never satisfy the default all mode

checkAuthoritative: true discovers every A and AAAA address behind the zone's authoritative nameservers and, in the default verificationMode: "all", requires every one of them, including the IPv6 addresses, to confirm the record before validation proceeds. If the box running the agent has no IPv6 route at all (common on WSL, some containers, and IPv6-disabled hosts), those specific queries fail with network unreachable on every attempt, and the poll runs out the full timeoutMs and fails even though the record is genuinely live and visible everywhere the host can actually reach (confirm with dig @1.1.1.1 _acme-challenge.<domain> TXT). The symptom is indistinguishable from slow propagation, so raising timeoutMs does not help and will not help. Diagnose with dig @<AAAA-ns-ip> ... directly from the agent host; a network unreachable there (not a timeout, not NXDOMAIN) confirms the cause. Fix it locally, not by changing the DNS provider: set checkAuthoritative: false and provide explicit IPv4 resolvers (for example ["1.1.1.1", "8.8.8.8"]) so the poll only queries addresses the host can actually route to.

Provider credentials

Each provider gets its own JSON credentials file. Create it with tight permissions before pointing the agent at it:

sudo install -d -m 0700 -o tokentimer-agent -g tokentimer-agent /etc/tokentimer-agent/dns
sudo install -m 0600 -o tokentimer-agent -g tokentimer-agent /dev/null \
/etc/tokentimer-agent/dns/cloudflare.json

The agent refuses a credentials file that is group- or other-readable on POSIX hosts, so a permissions mistake fails loudly instead of silently widening access.

Grant the narrowest scope each provider supports. A DNS-01 credential only ever needs to create and delete TXT records in the zones you use for certificates.

Cloudflare

{ "apiToken": "<scoped API token>" }

Use a scoped API token with Zone.DNS:Edit, not a global API key. Optional zoneId skips the zone lookup; without it the zone is resolved by name.

Amazon Route 53

{
"accessKeyId": "...",
"secretAccessKey": "...",
"sessionToken": "optional",
"hostedZoneId": "optional",
"region": "us-east-1"
}

Without hostedZoneId the agent calls ListHostedZonesByName; with it set, that call is skipped entirely. Every present/cleanup call also does a read-modify-write: it first calls ListResourceRecordSets to fetch the existing TXT values at the name, then ChangeResourceRecordSets to UPSERT the merged set (or DELETE it once empty). A minimal IAM policy needs exactly these three actions -- GetChange is not one of them, since the agent never polls change status:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "route53:ListHostedZonesByName",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"route53:ListResourceRecordSets",
"route53:ChangeResourceRecordSets"
],
"Resource": "arn:aws:route53:::hostedzone/<hostedZoneId>"
}
]
}

ListHostedZonesByName has no per-zone resource-level permissions in IAM (it must be Resource: "*"), so setting hostedZoneId explicitly and dropping that statement entirely is the tighter option when you can.

Azure DNS

{
"tenantId": "...",
"clientId": "...",
"clientSecret": "...",
"subscriptionId": "...",
"resourceGroup": "..."
}

Uses the client-credentials flow. Assign the DNS Zone Contributor role, scoped to the zone rather than the subscription where possible.

Create the app registration, secret, and scoped role assignment with the Azure CLI:

# 1. App registration -- clientId/tenantId/clientSecret all come from here.
az ad app create --display-name "tokentimer-certops-dns"
appId=$(az ad app list --display-name "tokentimer-certops-dns" --query "[0].appId" -o tsv)

# 2. Service principal -- a role cannot be assigned to the app until this exists.
az ad sp create --id "$appId"

# 3. Client secret (clientSecret). Copy the "password" value now; the
# Portal and CLI never show it again after this command returns.
az ad app credential reset --id "$appId" --display-name tokentimer --years 1

# 4. Scope the role assignment to the DNS zone resource itself, not the
# subscription or resource group.
zoneId=$(az network dns zone show -g <resourceGroup> -n <zone> --query id -o tsv)
az role assignment create \
--assignee "$appId" \
--role "DNS Zone Contributor" \
--scope "$zoneId"

tenantId is the directory (tenant) ID on the app registration's Overview page; clientId is that same page's Application (client) ID, not the service principal's Object ID. subscriptionId/resourceGroup identify where the zone lives, which can be a different subscription than the app registration.

"App registration" vs "Enterprise application"

The Portal shows the same underlying identity in two different blades. App registrationCertificates & secrets is where clientSecret is created. The Enterprise application list is a read-only, consumer-facing projection of the same service principal and has no secrets blade at all -- if you cannot find "Certificates & secrets" there, you are on the wrong blade, not missing a permission. Likewise, when assigning the role from the zone's Access control (IAM)Add role assignmentMembers step, search by the app registration's display name (not its clientId) under "User, group, or service principal" -- it will not appear in an "Enterprise application"-scoped member picker.

Google Cloud DNS

{
"client_email": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...",
"project_id": "...",
"managedZone": "optional"
}

These are the standard service-account JSON fields, so you can use the downloaded key file as-is. The service-account key signs the OAuth JWT locally and never leaves the host.

Grant the service account the Cloud DNS Administrator (roles/dns.admin) role. Cloud DNS has no narrower predefined role, but you can still scope the grant to one managed zone instead of the whole project:

gcloud dns managed-zones add-iam-policy-binding <managedZone> \
--member="serviceAccount:<client_email>" \
--role="roles/dns.admin"
note

This file contains a service-account private key. That is a DNS API credential, not certificate key material, so it is expected here. It is still the most sensitive file in this list: keep it 0600 and rotate it like any other cloud credential.

RFC 2136 (BIND, Knot, and compatible)

{
"server": "ns1.internal.example.net",
"keyName": "acme-update",
"keySecretBase64": "...",
"port": 53,
"keyAlgorithm": "hmac-sha256"
}

Dynamic DNS updates authenticated with TSIG (hmac-sha1, -sha224, -sha256, -sha384, -sha512). Restrict the TSIG key server-side to _acme-challenge TXT updates in the zones you need. This is the usual choice for internal zones that no public API can reach.

acme-dns

{
"baseUrl": "https://acme-dns.example.com",
"username": "...",
"password": "...",
"subdomain": "..."
}

All four values come from the acme-dns /register call. acme-dns is a good fit when you want to delegate _acme-challenge via CNAME and hand out a credential that can do nothing except answer challenges.

Cleanup is intentionally a no-op

acme-dns rotates its two TXT slots automatically, so there is nothing to delete. The provider declares cleanupVerifiable: false, the agent skips the usual wait-for-record-removal poll, and evidence records cleanup_not_applicable. A "cleanup did not happen" reading of that evidence is expected behaviour, not a failure.

OVHcloud

{
"applicationKey": "...",
"applicationSecret": "...",
"consumerKey": "...",
"endpoint": "https://eu.api.ovh.com/1.0"
}

Regional endpoints: https://eu.api.ovh.com/1.0 (default), https://ca.api.ovh.com/1.0, https://api.us.ovhcloud.com/1.0. Every mutation is followed by a zone refresh call, because OVH does not serve a changed zone until it is refreshed.

Host clock matters

OVH requests are signed with the host's local time. A significantly skewed clock produces signature failures. Keep NTP working on the agent host (which the agent's clock-drift reporting also depends on).

Hetzner

{ "apiToken": "<Hetzner Console / Cloud project API token>" }
Use the Cloud project token, not the legacy DNS token

This provider uses the Hetzner Cloud API (api.hetzner.cloud/v1) with Authorization: Bearer. A legacy Hetzner DNS Console token (dns.hetzner.com, Auth-API-Token header) is a different credential and is not supported. Using the wrong one fails authentication with no way for the agent to tell you which token type you pasted.

Optional zoneId skips the zone lookup. Record changes use value-specific add_records / remove_records actions, so concurrent challenges never clobber each other.

Primary zones only

add_records/remove_records are only supported for zones in Hetzner's primary mode; a secondary (slave) zone rejects both with 422 incorrect_zone_mode. This is virtually always what you want (secondary zones aren't editable through this API at all, by design), but is worth knowing if you see that exact error.

Infomaniak

{ "apiToken": "<API token with domain:read, dns:read, dns:write scopes>" }

Create the token with the domain:read, dns:read, and dns:write scopes. Infomaniak's v2 API (used here) documents these three scopes as required for record writes; the older single domain scope predates v2 and is not guaranteed sufficient, so use the three scopes above rather than the single legacy one. Infomaniak wraps every response in a { result, data } envelope, and the agent treats a non-success result as a failure even when the HTTP status is 200, so partial failures are not mistaken for success.

Token scope is account-wide, not per-domain

Infomaniak's token model has no per-domain restriction: a token with dns:write can write to every DNS zone on the account, not just the one you intend to automate. Scope the credentials file's blast radius with the same discipline you would use for a wildcard API key, and prefer a short expiry over a long-lived token if you are testing against a zone that also serves production traffic.

Exoscale

{
"apiKey": "...",
"apiSecret": "...",
"apiEndpoint": "https://api-ch-gva-2.exoscale.com/v2"
}

Requests are EXO2-HMAC-SHA256 signed. Exoscale DNS is global, so any zone endpoint works. Mutations are applied asynchronously on Exoscale's side: the accepted operation counts as success and the propagation wait covers the apply window.

PowerDNS

{
"apiUrl": "https://pdns.example.com:8081",
"apiKey": "...",
"serverId": "localhost"
}

apiUrl must be https:. A plain-HTTP loopback endpoint is allowed only with an explicit opt-in, because the API key would otherwise travel in clear text:

{
"apiUrl": "http://127.0.0.1:8081",
"apiKey": "...",
"allowInsecureLocalHttp": true
}

Embedded credentials in the URL and hash fragments are always rejected. The agent handles PowerDNS's trailing-dot names and quoted TXT content for you, and merges parallel challenges into one record set rather than overwriting.

Policy allowlisting

Configuring a provider is not enough on its own. The agent's local policy is default-deny, so the provider id and the DNS zones must also be allowed:

{
"policy": {
"allowedDnsProviders": ["cloudflare", "rfc2136"],
"allowedDnsZones": ["example.com", "internal.example.net"]
}
}

Provider ids are matched exactly. Valid ids are: cloudflare, route53, azure-dns, google-cloud-dns, rfc2136, acme-dns, ovhcloud, hetzner, infomaniak, exoscale, powerdns.

Local policy always wins over control-plane intent: a job asking for a provider or zone the host has not allowed is rejected locally, and the rejection is reported back as evidence.

Reading hook output when a challenge fails

When you invoke the solver through certbot's --manual-auth-hook/--manual-cleanup-hook (or acme.sh's equivalent), certops-dns-hook communicates entirely through exit codes and its two output streams. Knowing the split makes a failed challenge quick to diagnose.

Exit codes:

Exit codeMeaning
0The challenge record was published (or cleaned up) and verified
1Operational failure: policy rejection, missing dnsProviders config, unreadable credentials, provider API error, or propagation timeout
2Usage error: unknown mode (something other than present/cleanup), or a missing CERTBOT_DOMAIN/CERTBOT_VALIDATION (ACME_DOMAIN/ACME_TXT_VALUE) environment variable

A 2 means the hook was wired up wrong and never got as far as your DNS provider. A 1 means it tried and something refused.

Streams:

  • stdout carries structured progress as one JSON object per line, including a dns.propagation event with the provider, zone, record name, attempt count, elapsed time, and the resolvers consulted. This is what you want when a challenge is slow rather than broken.
  • stderr carries failures. Policy rejections and provider errors are written as a JSON verdict object, so you get a machine-readable reason rather than prose. Usage errors (exit 2) are plain text prefixed with certops-dns-hook:.

The hook fails loud and never fails open. If it cannot resolve the zone, cannot read credentials, is refused by local policy, or cannot confirm the record propagated, it exits non-zero so the ACME client aborts before asking the CA to validate. A silent "success" that leaves no TXT record would burn a CA validation attempt and, with some CAs, count against your rate limit.

One consequence worth knowing: credentials are never echoed. Provider secrets are read from the credentials file inside the hook and are excluded from every log line, evidence record, and error message, including the JSON verdict on stderr. If you are debugging an authentication failure, you will see that auth failed, never the token that failed. Verify credentials against the provider directly instead.

Policy checks run in a deliberate order: provider allowlist first, then zone allowlist, and only then is the credentials file read. A provider or zone your host has not allowed is rejected before any secret is loaded from disk and before any outbound API call.

Troubleshooting

SymptomLikely causeFix
Hook exits 2 immediatelyMissing CERTBOT_DOMAIN/CERTBOT_VALIDATION, or the hook was called without present/cleanupCheck how the hook is wired into certbot/acme.sh; see hook output
Provider rejected before any API callProvider id not in policy.allowedDnsProviders, or zone not in allowedDnsZonesAdd both; ids are exact-match
Agent refuses to read the credentials fileFile is group- or other-readablechmod 0600 and set the owner to the agent user
Propagation timeoutSlow provider, or a resolver that lagsRaise timeoutMs, or switch to verificationMode: "quorum" with a quorumCount
Authentication fails and the log does not show the tokenWorking as designed: secrets are never loggedVerify the credential against the provider directly
Hetzner authentication fails with a valid-looking tokenLegacy DNS Console token used instead of a Cloud project tokenCreate a Cloud project API token (see Hetzner)
OVH signature errorsHost clock skewFix NTP on the agent host
PowerDNS rejects the URLapiUrl uses http: without allowInsecureLocalHttp, or the host is not a loopback addressUse https:, or opt in explicitly and point at 127.0.0.1/localhost. Non-loopback addresses (including a Docker bridge IP) are refused over plain HTTP even with the opt-in
Wrong zone chosen on a multi-provider hostZone discovery picked a different zone than intendedAdd an explicit zoneProviderMap entry
acme-dns evidence says cleanup not applicableExpected: acme-dns rotates its TXT slotsNo action needed