Skip to content
integration

Audit your domain's email security on every push with GitHub Actions

| 7 min read
GitHub Actions workflow running in a terminal
Photo by Roman Synkevych on Unsplash

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:

  1. Calls the botoi /v1/dns-security/spf-check endpoint to validate your SPF record
  2. Calls /v1/dns-security/dmarc-check to validate your DMARC policy
  3. Calls /v1/dns-security/dkim-check to verify your DKIM key is published
  4. 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.

GitHub repository interface showing Actions tab
The workflow runs on every push and posts results to the Actions summary Photo by Roman Synkevych on Unsplash

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 with v=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. +all defeats 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. reject drops them, quarantine sends them to spam, none takes 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:

  1. Go to your repo's Settings > Secrets and variables > Actions
  2. Add a secret named BOTOI_API_KEY
  3. 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 with v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com to monitor before enforcing.
  • DMARC policy is "none": This is fine during rollout. Once you confirm legitimate email passes SPF and DKIM, move to p=quarantine and then p=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.