Reviewing an SPF change in a DNS pull request is guesswork. An edit that drops a sender looks like any other include: line, and you find out when that sender's mail starts bouncing. This guide adds a free GitHub Action that tests the proposed record against live DNS. The action fails the pull request when a sender would lose its pass.
The recipes further down print the proposed record from octoDNS, DNSControl, Terraform or a plain file.
Add the SPF check to your DNS repository
- Add a step that prints the proposed SPF record for your domain's root name, using the recipe for your DNS tool.
- Add the DomainCanary SPF pre-flight action as the next step, and pass it your domain and the proposed record.
- To test the senders in your DMARC reports as well, create an API key on your account page and store it as a repository secret called
DOMAINCANARY_API_KEY. - Mark the SPF check as required in your branch protection rules, so GitHub blocks the merge when the check fails.
This workflow reads the record from dns/spf.txt:
The record step fails when dns/spf.txt holds a line break. GitHub reads each line written to $GITHUB_OUTPUT as a separate output, so a second line starting with spf= would replace the record the action checks.
With comment: true, the action posts its result as a comment on the pull request, so the reviewer can read it beside the diff. The verdict output is pass, warn, fail or unchanged.
What fails the pull request
The action sends your proposed record to our SPF change check. We resolve every include in your live record and in the proposed one, and count the DNS lookups each needs. Then we take addresses from every IP range in the two records and work out the SPF result each address gets under each record. We read the terms in order, as receivers such as Gmail and Outlook do.
The check fails when the proposed record does any of these:
- needs more than 10 DNS lookups
- ends in
+all, or includes or redirects to a record that does - adds an include of a name with no SPF record, or with two of them
- adds an include of a name that includes your domain back
- adds a term receivers don't recognize, or a malformed one such as
include:with no domain,a/99,include:mail..example.comor a macro receivers can't expand - turns a tested address from a pass into a fail, softfail, neutral or permerror
The test of each address catches a reordered record as well as a removed range. Moving a -ip4: term ahead of an include that lists the same address fails the check, because receivers stop at the first term that matches.
The check fails on a broken include or a malformed term only when your change adds it, so a fault already in your live record doesn't block the pull request. The 10-lookup limit and a +all ending fail the check every time.
What gives a warning
The check warns when it can't work out the result for every address:
- the change adds or removes
a,mx,ptr,existsor a macro, whose addresses only resolve when a message arrives - we couldn't read the SPF record at a name the change adds or removes
- the records need more DNS queries, or hold more address ranges, than the check has room for
The check also warns when your domain already publishes two SPF records or more. A change that replaces an include with a name spelled almost the same way gets a warning too.
Set fail-on-warn: true to fail on warnings as well.
Any answer that isn't a verdict also fails the check: a rate limit, a revoked key, a DNS lookup that didn't finish, or our service not answering. So a check that couldn't run never shows as passed.
With and without an API key
Without a key, we test addresses from the ranges in your live record and the proposed one. A removed range fails the check as soon as one of its addresses loses its pass, whether or not anything still sends from it.
With a key, we also test every sender in your domain's DMARC aggregate reports from the last 90 days. When a removed range still sent mail, the result gives the number of senders, the number of messages and the date of the last one. If no sender in your reports used the removed range, the result says no sender used it. The removal still fails the check.
For the report check, add the domain to your DomainCanary account and verify it. Then send the domain's DMARC reports to DomainCanary. If the report check can't run, the result says why.
Print the proposed record from your DNS tool
Each recipe searches the tool's output for a string starting with v=spf1, because field names change between versions of these tools. Run each recipe on its own once and check that it prints the record you expect.
octoDNS: replace the record step in the workflow above with this one, and change zones/example.com.yaml to your zone file. It needs mikefarah's yq, which GitHub's Ubuntu runners include.
The octoDNS step fails when the root of the zone has no SPF record, or two different ones. Receivers treat two TXT records starting with v=spf1 as a permerror. If the octoDNS step didn't count the SPF records, only the first record would reach the action.
Receivers ignore the old SPF record type. If your zone still has an SPF record that matches the TXT record, the step treats the two as the same record. If the SPF and TXT records differ, the step fails until you delete the SPF record.
With split_extension set, octoDNS keeps the root records in 'zones/example.com./$example.com.yaml'. Use that path in place of zones/example.com.yaml, in single quotes so the shell leaves the $ alone.
DNSControl prints its zones as JSON with print-ir:
Terraform: the command searches every string in the planned change, so it finds the record whichever field your provider uses, such as Cloudflare's content or value or Route 53's records.
Some providers want the full name with a trailing dot, or @, in name. Set --arg name to whatever your resources use.
A plain file: cat dns/spf.txt.
For DNSControl, Terraform and a plain file, put the command inside spf=$(...) in the record step of the first workflow, so the line-break check runs on the record it prints.
Records split across several names
Each run of the action checks the record you pass as record, for the name you pass as domain. We read every record it includes from live DNS, as you publish it today. So when SPF_BUILDER or your own setup splits a long record into includes such as _spf1.example.com, a pull request that changes only one of those includes looks unchanged to a check of the root.
Run the action once more for each of your own names the pull request changes, with that name as domain and its proposed record as record.
Pull requests from forks
GitHub doesn't pass secrets to a workflow that runs on a pull request from a fork. On those runs the action checks without a key. The action tests the address ranges in your live and proposed records, and skips the senders in your reports. Don't switch the workflow to pull_request_target to get the key into those runs. A pull_request_target workflow runs with your secrets, so checking out the fork's code in it hands those secrets to whoever opened the pull request.
GitLab, Bitbucket and other CI
The action sends a POST request to our API, and you can send the same request from any CI system:
To check without a key, leave out the Authorization line. Fail the job when verdict is fail, and on any response that isn't a 200 with a verdict field. The GitHub Action fails on the same responses.
The report check uses the DMARC reports your domain already receives, once they arrive in a DomainCanary account. Our weekly digest lists which senders passed and which failed in those reports. The digest is free for your first domain.
Diff the change before you publish it
Paste the record you are about to publish and see what it drops.
Free · No signup · The result names what to change
Questions
Does the action read my octoDNS or DNSControl files?
No. The action takes the proposed record as text, which an earlier step in your workflow prints from your DNS tool. The recipes on this page print the record from octoDNS, DNSControl, Terraform or a plain file.
Is it safe to use the API key in a public repository?
Store the key as a repository secret. GitHub then keeps the key out of logs and out of pull requests from forks. The key belongs to your whole account. If it ever leaks, revoke it on your account page and create a new key.
What does the check cost?
Nothing. The action works without a key. For the report check, API keys come with every plan, including the free plan. Each account gets 20 checks in any 10 minutes and 100 a day.
Keep reading
Checking as you go? The DMARC checker reads the policy you are moving up, the SPF and DKIM checkers show whether your senders will survive it, and the report analyzer reads an aggregate report you already have. No signup.
DomainCanary is a DMARC enforcement service that protects your domain from email spoofing without blocking your own mail.