Audit your domain's email security on every push with GitHub Actions
Someone on your team updates a DNS record. A TXT entry gets deleted during a provider migration. Your SPF record silently exceeds the 10-lookup limit. Emails start landing in spam. Nobody notices for two weeks until a customer mentions they never got your invoice.
DNS-based email security records (SPF, DMARC, DKIM) are the kind of infrastructure that works perfectly until it doesn't. When it breaks, the failure mode is silent: no errors, no alerts, emails vanish into spam folders.
This guide sets up a GitHub Action that validates all three records on every push. If any record is missing or misconfigured, the workflow fails and tells you what broke.
What the workflow does
On every push to main, the action:
- Calls the botoi
/v1/dns-security/spf-checkendpoint to validate your SPF record - Calls
/v1/dns-security/dmarc-checkto validate your DMARC policy - Calls
/v1/dns-security/dkim-checkto verify your DKIM key is published - Exits with a non-zero code if any check fails, blocking the merge
The GitHub Action workflow
Create .github/workflows/dns-security.yml in your repository:
name: DNS Security Audit
on:
push:
branches: [main]
schedule:
# Run daily at 9:00 UTC to catch out-of-band DNS changes
- cron: '0 9 * * *'
workflow_dispatch:
env:
DOMAIN: yourdomain.com
DKIM_SELECTOR: google
jobs:
dns-security-check:
runs-on: ubuntu-latest
steps:
- name: Check SPF record
id: spf
run: |
RESPONSE=$(curl -s -X POST https://api.botoi.com/v1/dns-security/spf-check \
-H "Content-Type: application/json" \
-d "{\"domain\": \"$DOMAIN\"}")
HAS_SPF=$(echo "$RESPONSE" | jq -r '.data.has_spf')
VALID=$(echo "$RESPONSE" | jq -r '.data.valid')
RECORD=$(echo "$RESPONSE" | jq -r '.data.record // "none"')
ALL_POLICY=$(echo "$RESPONSE" | jq -r '.data.all_policy // "none"')
echo "## SPF Check" >> $GITHUB_STEP_SUMMARY
echo "- Record: \`$RECORD\`" >> $GITHUB_STEP_SUMMARY
echo "- Valid: $VALID" >> $GITHUB_STEP_SUMMARY
echo "- All policy: $ALL_POLICY" >> $GITHUB_STEP_SUMMARY
if [ "$HAS_SPF" != "true" ] || [ "$VALID" != "true" ]; then
echo "::error::SPF check failed. has_spf=$HAS_SPF valid=$VALID record=$RECORD"
exit 1
fi
if [ "$ALL_POLICY" = "+all" ]; then
echo "::warning::SPF uses +all which allows any server to send email as your domain"
fi
echo "SPF check passed: $RECORD"
- name: Check DMARC record
id: dmarc
run: |
RESPONSE=$(curl -s -X POST https://api.botoi.com/v1/dns-security/dmarc-check \
-H "Content-Type: application/json" \
-d "{\"domain\": \"$DOMAIN\"}")
HAS_DMARC=$(echo "$RESPONSE" | jq -r '.data.has_dmarc')
POLICY=$(echo "$RESPONSE" | jq -r '.data.policy // "none"')
PCT=$(echo "$RESPONSE" | jq -r '.data.pct // "0"')
RUA=$(echo "$RESPONSE" | jq -r '.data.reporting.rua // []')
echo "## DMARC Check" >> $GITHUB_STEP_SUMMARY
echo "- Policy: $POLICY" >> $GITHUB_STEP_SUMMARY
echo "- Percentage: $PCT%" >> $GITHUB_STEP_SUMMARY
echo "- Reporting (rua): $RUA" >> $GITHUB_STEP_SUMMARY
if [ "$HAS_DMARC" != "true" ]; then
echo "::error::No DMARC record found for $DOMAIN"
exit 1
fi
if [ "$POLICY" = "none" ]; then
echo "::warning::DMARC policy is 'none' which only monitors without enforcing"
fi
echo "DMARC check passed: policy=$POLICY pct=$PCT"
- name: Check DKIM record
id: dkim
run: |
RESPONSE=$(curl -s -X POST https://api.botoi.com/v1/dns-security/dkim-check \
-H "Content-Type: application/json" \
-d "{\"domain\": \"$DOMAIN\", \"selector\": \"$DKIM_SELECTOR\"}")
HAS_DKIM=$(echo "$RESPONSE" | jq -r '.data.has_dkim')
KEY_TYPE=$(echo "$RESPONSE" | jq -r '.data.key_type // "unknown"')
KEY_LENGTH=$(echo "$RESPONSE" | jq -r '.data.public_key_length // "0"')
echo "## DKIM Check" >> $GITHUB_STEP_SUMMARY
echo "- Selector: $DKIM_SELECTOR" >> $GITHUB_STEP_SUMMARY
echo "- Key type: $KEY_TYPE" >> $GITHUB_STEP_SUMMARY
echo "- Key length: $KEY_LENGTH bits" >> $GITHUB_STEP_SUMMARY
if [ "$HAS_DKIM" != "true" ]; then
echo "::error::No DKIM record found for selector '$DKIM_SELECTOR' on $DOMAIN"
exit 1
fi
if [ "$KEY_LENGTH" -lt 2048 ] 2>/dev/null; then
echo "::warning::DKIM key is $KEY_LENGTH bits. 2048 bits is the recommended minimum."
fi
echo "DKIM check passed: key_type=$KEY_TYPE key_length=$KEY_LENGTH"
- name: Summary
if: success()
run: |
echo "---" >> $GITHUB_STEP_SUMMARY
echo "All DNS security checks passed for **$DOMAIN**" >> $GITHUB_STEP_SUMMARY
Replace yourdomain.com with your domain and google with
your email provider's DKIM selector. The workflow runs on every push to main,
on a daily schedule, and can be triggered manually from the Actions tab.
What each check validates
SPF (Sender Policy Framework)
SPF declares which mail servers are authorized to send email for your domain. The API returns the raw record, a parsed list of mechanisms, and whether the record is valid.
Key fields to watch:
-
has_spf: Is there a TXT record starting withv=spf1? If false, any server can claim to send email from your domain. -
valid: Does the record parse correctly? SPF records break when they exceed the 10 DNS lookup limit or contain syntax errors. -
all_policy: The trailing mechanism.-all(hard fail) is the strongest setting.~all(soft fail) marks unauthorized mail as suspicious.+alldefeats the purpose of SPF entirely.
Example API response for a healthy SPF record:
{
"success": true,
"data": {
"domain": "yourdomain.com",
"has_spf": true,
"record": "v=spf1 include:_spf.google.com ~all",
"mechanisms": ["include:_spf.google.com", "~all"],
"all_policy": "~all",
"includes": ["_spf.google.com"],
"valid": true
}
} DMARC (Domain-based Message Authentication, Reporting, and Conformance)
DMARC tells receiving servers what to do when SPF or DKIM checks fail. Without it, even valid SPF and DKIM records don't prevent spoofing.
Key fields to watch:
-
policy: What happens to failing messages.rejectdrops them,quarantinesends them to spam,nonetakes no action (monitoring only). -
pct: The percentage of messages the policy applies to. Start at a low number during rollout, then move to 100. -
reporting.rua: Where aggregate reports are sent. Without this, you have no visibility into authentication failures.
Example API response for a DMARC record:
{
"success": true,
"data": {
"domain": "yourdomain.com",
"has_dmarc": true,
"record": "v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com; pct=100",
"policy": "reject",
"subdomain_policy": null,
"reporting": {
"rua": ["mailto:dmarc@yourdomain.com"],
"ruf": []
},
"pct": 100,
"alignment": {
"dkim": "r",
"spf": "r"
}
}
} DKIM (DomainKeys Identified Mail)
DKIM adds a cryptographic signature to outgoing messages. Receiving servers verify the signature against a public key published in your DNS. If the key is missing or rotated without updating DNS, signature verification fails.
Key fields to watch:
-
has_dkim: Is a DKIM key published for the given selector? Each email provider uses a different selector name. -
public_key_length: NIST recommends 2048 bits minimum. Keys shorter than 1024 bits are considered weak. -
key_type: Most keys use RSA. Ed25519 keys are smaller and faster but have limited support across mail providers.
Example API response for a DKIM check:
{
"success": true,
"data": {
"domain": "yourdomain.com",
"selector": "google",
"has_dkim": true,
"record": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
"key_type": "rsa",
"public_key_length": 2048
}
} Common DKIM selectors by provider
| Email provider | DKIM selector(s) |
|---|---|
| Google Workspace | google |
| Microsoft 365 | selector1, selector2 |
| Amazon SES | UUID-based (check your SES dashboard) |
| Mailchimp / Mandrill | k1 |
| SendGrid | s1, s2 |
| Postmark | Generated per domain (check DNS settings) |
Extending the workflow
Multiple domains
If you manage several domains, use a matrix strategy to check each one. Add a botoi API key as a GitHub secret to avoid hitting the free-tier rate limit.
jobs:
dns-security-check:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- domain: yourdomain.com
dkim_selector: google
- domain: yourstartup.io
dkim_selector: selector1
- domain: yourblog.dev
dkim_selector: google
env:
DOMAIN: ${{ matrix.domain }}
DKIM_SELECTOR: ${{ matrix.dkim_selector }} Slack notifications on failure
Add a notification step that fires when any check fails. This uses the official Slack GitHub Action:
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v2.0.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"text": ":rotating_light: DNS security check failed for ${{ env.DOMAIN }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*DNS Security Audit Failed*\nDomain: \`${{ env.DOMAIN }}\`\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
}
]
} Monorepo setup
In a monorepo, you probably don't want DNS checks running on every push to every package. Scope the trigger to changes in infrastructure-related files:
on:
push:
branches: [main]
paths:
- 'infrastructure/**'
- 'terraform/**'
- '.github/workflows/dns-security.yml'
schedule:
- cron: '0 9 * * *' The scheduled trigger still runs daily regardless of path filters, so you catch DNS changes made outside the repository.
Using an API key for higher rate limits
If you check multiple domains or run the workflow frequently, add your botoi API key as a GitHub Actions secret:
- Go to your repo's Settings > Secrets and variables > Actions
- Add a secret named
BOTOI_API_KEY - Add the auth header to each curl command:
curl -s -X POST https://api.botoi.com/v1/dns-security/spf-check \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.BOTOI_API_KEY }}" \
-d "{\"domain\": \"$DOMAIN\"}" What to do when checks fail
- Missing SPF record: Add a TXT record to your domain's DNS. Start with
v=spf1 include:_spf.google.com ~all(replace the include with your email provider's SPF domain). - Invalid SPF record: You likely hit the 10 DNS lookup limit. Use an
SPF flattening tool to replace
include:mechanisms with IP addresses, or consolidate providers. - Missing DMARC record: Add a TXT record at
_dmarc.yourdomain.com. Start withv=DMARC1; p=none; rua=mailto:dmarc@yourdomain.comto monitor before enforcing. - DMARC policy is "none": This is fine during rollout. Once you confirm
legitimate email passes SPF and DKIM, move to
p=quarantineand thenp=reject. - Missing DKIM record: Verify you have the correct selector for your
email provider (see the table above). The key must be published as a TXT record at
[selector]._domainkey.yourdomain.com. - DKIM key too short: Rotate your DKIM key to 2048 bits through your email provider's admin panel, then update the DNS TXT record.
Frequently asked questions
- Do I need a botoi API key for this workflow?
- No. The free tier allows 5 requests per minute with no API key. The workflow makes 3 requests per run (SPF, DMARC, DKIM), which fits within the limit. If you run checks on multiple domains or selectors, add your API key as a GitHub secret and pass it in the Authorization header.
- Can I check multiple domains in one workflow run?
- Yes. Loop over an array of domains in the check script. Each domain requires 3 API calls, so a free-tier run handles one domain per invocation. For multiple domains, add a botoi API key to avoid rate limiting.
- What DKIM selector should I use?
- The selector depends on your email provider. Google Workspace uses "google", Microsoft 365 uses "selector1" and "selector2", Amazon SES uses a UUID-based selector. Check your DNS TXT records for entries matching the pattern [selector]._domainkey.yourdomain.com.
- Will this workflow block my deploys?
- Only if a check fails, which means your email security records are missing or misconfigured. That is the point: you want to catch these issues before they cause deliverability problems. You can change the workflow to post a warning instead of failing by replacing "exit 1" with a step that creates a GitHub issue or sends a Slack message.
- How often should I run this check?
- On every push to your main branch is the baseline. Add a scheduled cron trigger (e.g., daily at 9am) to catch DNS changes made outside your repo, like when a teammate edits records in the registrar dashboard.
Try this API
Email Security Report API — interactive playground and code examples
More integration posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.