Publishing CAA Records to Restrict Certificate Issuance
After working through this guide you will be able to take a domain with no issuance policy at all and end up with a verified CAA record set that names exactly the certificate authorities you use, reports violations to a mailbox you monitor, pins issuance to one ACME account, and refuses any challenge type other than the one your automation actually performs.
The reason to do this in a fixed order is that CAA fails in an unhelpful direction. A record you forgot to publish blocks nothing; a record you published slightly wrong blocks everything, and the error arrives hours later inside an ACME client’s log rather than at the moment you saved the zone. So the sequence below deliberately widens before it narrows, and puts a real issuance test between every tightening step. The mechanism itself — tree climbing, property tags, parameter semantics — is covered in CAA Records & Certificate Automation; this guide is the execution.
Key implementation objectives:
- Enumerate every issuer currently signing for the domain, including ones provisioned by platforms on your behalf.
- Publish an apex record set plus only the subdomain overrides you can justify, understanding that each override detaches that branch.
- Harden the policy with
iodef,accounturiandvalidationmethodsincrementally, proving issuance after each change. - Verify the published set from authoritative and public resolvers before trusting it, then confirm with a live certificate order.
Prerequisites and environment setup
You need a resolver toolchain new enough to print CAA in presentation form. BIND 9.16 or later covers it; check with dig -v. You also need write access to the zone through an API token rather than a console session, because the verification loop below is much faster when publishing is scriptable.
dig -v # DiG 9.16.x or later
terraform version # 1.6+ if you manage the zone as code
jq --version # used to read the ACME account URI
certbot --version # or lego / acme.sh — any RFC 8555 client
Provider credentials should be scoped to DNS edits on the single zone. On Cloudflare that is a token with Zone / DNS / Edit on one zone; on AWS it is an IAM policy allowing route53:ChangeResourceRecordSets on one hosted zone ID.
export CF_API_TOKEN="…" # Cloudflare, zone-scoped
export ZONE_ID="…"
export HOSTED_ZONE_ID="Z…" # Route 53
export AWS_PROFILE="dns-admin"
One thing to settle before you start: know whether the zone is DNSSEC-signed. An unsigned zone lets a CA treat a stripped CAA answer as “no policy” under the Baseline Requirements’ lookup-failure allowance, which undercuts the whole exercise. If signing is still on your backlog, work through DNSSEC Operational Management first — a CAA policy on an unsigned zone documents intent but does not enforce it against an on-path attacker.
Step-by-step procedure
Step 1 — Inventory the CAs that are actually issuing
Start from evidence, not from memory. Walk every hostname that terminates TLS — public sites, API endpoints, internal load balancers, mail gateways, and any subdomain a SaaS vendor terminates for you — and read the issuer off the live certificate.
for host in example.com www.example.com api.example.com shop.example.com; do
printf '%-28s ' "$host"
echo | openssl s_client -connect "$host":443 -servername "$host" 2>/dev/null \
| openssl x509 -noout -issuer
done
example.com issuer=C=US, O=Let's Encrypt, CN=R11
www.example.com issuer=C=US, O=Let's Encrypt, CN=R11
api.example.com issuer=C=US, O=Amazon, CN=Amazon RSA 2048 M02
shop.example.com issuer=C=GB, O=Sectigo Limited, CN=Sectigo RSA DV
Then translate each organization into the issuer domain that CA publishes for CAA — letsencrypt.org, amazon.com, sectigo.com, pki.goog, digicert.com. The value is a CAA-specific identifier, not the company’s marketing domain, and inventing it produces a record that parses perfectly and matches nothing.
The step most teams skip is the platform issuers. A proxied Cloudflare zone renews edge certificates from Cloudflare’s own CA set; a Google Cloud load balancer with managed certificates issues from Google Trust Services; ACM issues from several Amazon roots. None of those appear in a manual inventory of “our CAs”, and all of them break silently when a CAA policy omits them.
Step 2 — Write the apex set, then the overrides
Draft the whole policy as text before touching a provider. The apex set is the union of everything from step 1 that signs for names without their own override, plus an issuewild entry for every CA that must sign wildcards.
; apex — governs every name that has no CAA set of its own
example.com. 300 IN CAA 0 issue "letsencrypt.org"
example.com. 300 IN CAA 0 issue "amazon.com"
example.com. 300 IN CAA 0 issuewild "letsencrypt.org"
; override — shop.* is signed by a different CA and never gets a wildcard
shop.example.com. 300 IN CAA 0 issue "sectigo.com"
shop.example.com. 300 IN CAA 0 issuewild ";"
Two details to get right here. issuewild ";" on shop forbids wildcards for that branch while still permitting Sectigo to sign specific names — that only works because a set containing any issuewild property causes issue to be ignored for wildcard orders. And the override does not inherit letsencrypt.org from the apex, so if anything under shop.example.com renews through Let’s Encrypt, that entry has to be repeated in the override.
Use a 300-second TTL while you iterate. It costs nothing and it removes resolver caching as a variable when a verification query disagrees with what you just published.
Step 3 — Add an iodef destination you actually read
iodef gives a CA somewhere to send a report when a request is refused. Support is uneven — several major CAs never send one — so treat it as a low-cost audit signal, not a monitoring integration. Point it at a distribution list rather than a person.
example.com. 300 IN CAA 0 iodef "mailto:[email protected]"
Leave the flags at 0. Setting the critical bit (128) on an iodef record forces any CA that does not implement iodef reporting to refuse issuance entirely, which is a self-inflicted outage with a confusing error message.
Step 4 — Pin the ACME account with accounturi
Account pinning is the step that turns CAA from “which company may issue” into “which key may issue”. Read the account URI out of your client’s registration file — it is the uri field Certbot stores, and lego and acme.sh expose the same value.
jq -r '.uri' /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/regr.json
https://acme-v02.api.letsencrypt.org/acme/acct/1234567
Append it to the issuer value, separated by a semicolon:
example.com. 300 IN CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567"
Apply the parameter only to issuers that implement RFC 8657. A CA that does not recognize a parameter is required to decline issuance rather than ignore it, so a parameter added blanket-wide will quietly disable your other CAs. Keep those entries parameterless in the same set.
Step 5 — Restrict validationmethods
If every certificate for this domain is obtained through DNS-01, say so. The parameter takes a comma-separated list drawn from http-01, dns-01 and tls-alpn-01.
example.com. 300 IN CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
example.com. 300 IN CAA 0 issuewild "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
The effect is that a compromised web server no longer yields a certificate, because HTTP-01 and TLS-ALPN-01 orders are refused before validation. The cost is that any manual fallback you had disappears too. Only apply this once your DNS-01 automation has run unattended for a few renewal cycles — the failure modes it introduces are catalogued in Troubleshooting CAA and DNS-01 Validation Failures.
Step 6 — Publish through your provider
On Cloudflare each record is a structured data object rather than a flat string:
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
--data '{"type":"CAA","name":"example.com","ttl":300,
"data":{"flags":0,"tag":"issue","value":"letsencrypt.org"}}' | jq '.success'
On Route 53 the entire record set is replaced in one change batch, so every record you want to keep must appear in the array:
aws route53 change-resource-record-sets --hosted-zone-id "$HOSTED_ZONE_ID" --change-batch '{
"Changes": [{ "Action": "UPSERT", "ResourceRecordSet": {
"Name": "example.com.", "Type": "CAA", "TTL": 300,
"ResourceRecords": [
{"Value": "0 issue \"letsencrypt.org\""},
{"Value": "0 issue \"amazon.com\""},
{"Value": "0 issuewild \"letsencrypt.org\""},
{"Value": "0 iodef \"mailto:[email protected]\""}
]}}]}'
In Terraform the whole policy becomes reviewable in a pull request, which is where it belongs — the broader pattern is in Managing DNS Zones as Code with Terraform:
locals {
acme_account = "https://acme-v02.api.letsencrypt.org/acme/acct/1234567"
}
resource "aws_route53_record" "caa_apex" {
zone_id = var.hosted_zone_id
name = "example.com"
type = "CAA"
ttl = 300
records = [
"0 issue \"letsencrypt.org; accounturi=${local.acme_account}; validationmethods=dns-01\"",
"0 issue \"amazon.com\"",
"0 issuewild \"letsencrypt.org; accounturi=${local.acme_account}; validationmethods=dns-01\"",
"0 iodef \"mailto:[email protected]\"",
]
}
Run terraform plan and read the diff carefully. Because the whole set is replaced, a plan that removes a line you did not intend to remove is the most likely way to widen your policy by accident.
Verification
Never trust the provider’s confirmation. Query the authoritative servers first, because that is the only answer with no cache in front of it:
NS=$(dig +short NS example.com | head -1)
dig @"$NS" +short CAA example.com
0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
0 issue "amazon.com"
0 issuewild "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
0 iodef "mailto:[email protected]"
Then repeat against public recursives, and against a validating query, so you catch a resolver that is serving a stale or unsigned answer. If the zone is signed, the ad flag must be present:
for r in 1.1.1.1 8.8.8.8 9.9.9.9; do
echo "--- $r"; dig @"$r" +short CAA example.com
done
dig @1.1.1.1 +dnssec CAA example.com | grep -E '^;; flags'
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1
The final proof is an actual issuance. Run a staging order so a failure costs nothing against rate limits, and force renewal so the client cannot short-circuit on a still-valid certificate:
certbot certonly --dry-run --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cf.ini \
-d example.com -d '*.example.com'
Simulating a certificate request for example.com and *.example.com
The dry run was successful.
If you tightened validationmethods, prove the negative as well: an HTTP-01 dry run against the same name should now fail with a CAA error. A tightening you cannot demonstrate is a tightening you should not assume.
Troubleshooting
Issuance stopped as soon as the record went live
The ACME client reports an error whose detail names CAA and one identifier. Find the set that actually governs that identifier by querying it and then each parent label in turn:
for n in api.example.com example.com; do echo "--- $n"; dig +short CAA "$n"; done
The first label that returns anything is the governing set. Nine times out of ten the issuer domain in it is wrong — letsencrypt.com instead of letsencrypt.org, or a CA’s brand name instead of its CAA identifier. Correct the value, or delete the record entirely to restore inheritance while you work out the right one.
Wildcards fail while specific names renew
Query the governing set and look for any issuewild property. Its mere presence causes issue to be disregarded for wildcard orders, so a set with issue "letsencrypt.org" and issuewild "digicert.com" blocks Let’s Encrypt from signing *.example.com even though it is plainly listed.
dig +short CAA example.com | grep issuewild
The fix is to add issuewild "letsencrypt.org" alongside, or to remove the issuewild records so issue governs both classes again. Note that a wildcard order evaluates the policy at the parent name — publishing anything at the literal *.example.com node has no effect whatsoever.
The record exists but the CA behaves as though it does not
This is aliasing. A CAA query for a CNAME’d name is resolved through the alias, so the answer comes from the target’s zone. Check for the alias explicitly:
dig +noall +answer CAA www.example.com
www.example.com. 300 IN CNAME edge.cdn-vendor.net.
An answer section that contains only a CNAME and no CAA means the target zone has no policy, and whether the CA then climbs from your parent or the vendor’s is not something you should rely on. Either publish the policy in a zone you control at the alias target, or move the certificate to a name that is not aliased — the trade-offs are laid out in CNAME Flattening Explained.
A delegated subdomain behaves unexpectedly
Delegating ops.example.com to a child zone does not exempt it from the apex policy. If the child zone has no CAA records anywhere, the climb continues past the delegation into the parent zone and the apex set applies. Teams discover this when a subsidiary running its own nameservers cannot get a certificate from a CA the parent organization never listed.
dig +short NS ops.example.com
dig +short CAA ops.example.com
dig +short CAA example.com
If the child needs a different policy, it must publish its own CAA set — inside its own zone, at or above the names it certifies.
The record is syntactically valid but semantically wrong
Quoting mistakes produce records that publish cleanly and enforce the wrong thing. On Route 53 the value is a presentation-form string containing literal quotes, so "Value": "0 issue letsencrypt.org" without the inner quotes is a different record from "Value": "0 issue \"letsencrypt.org\"". In a BIND zone file an unquoted semicolon starts a comment, so 0 issue ; truncates rather than forbidding. And a parameter separated by a comma instead of a semicolon becomes part of the issuer domain, matching nothing.
dig +noall +answer CAA example.com
Read the answer section rather than +short when you suspect quoting: the full presentation form shows exactly how the server parsed the value, which is the only opinion that matters.
Frequently Asked Questions
What TTL should I use for CAA records? Use 300 seconds while you are iterating and 3600 once the policy is stable. Anything shorter buys nothing, because a CA may reuse its cached CAA result for up to eight hours regardless of your TTL, and anything longer just makes an emergency widening slower without a compensating benefit.
Do I need a CAA record on every subdomain? No, and you generally should not. A name with no CAA set inherits whatever the nearest ancestor publishes, which keeps the policy in one reviewable place. Add a subdomain set only when that branch genuinely needs a different issuer, and remember it replaces the apex policy for that branch rather than adding to it.
Will publishing CAA break my existing certificates? Not the ones already issued — CAA is evaluated when a certificate is signed, never when it is presented, so live certificates keep working until they expire. What it can break is the next renewal, which is why the procedure above tests an issuance after each tightening step rather than at the end.
Can I use accounturi with more than one ACME account?
Yes. Publish one issue record per account, each carrying its own accounturi parameter, and a request matching any of them is permitted. This is how you cover a staging account, a disaster-recovery client, or a second environment without widening the policy to the whole CA.
Should I add CAA before or after enabling DNSSEC? Publish CAA whenever you are ready, but understand that until the zone is signed a CA is permitted to treat a failed lookup as absence of policy, so an attacker who can suppress your answers also suppresses your restrictions. Signing the zone is what converts CAA from documented intent into an enforceable control.