Network Security

Rolling Out DMARC Enforcement at Scale

A practical guide to deploying DMARC across a large platform — SPF, DKIM, and alignment fixes across AWS SES, Google Workspace, Postfix relays, and dozens of domains

Laurent Goudet · February 28, 2026 · 16 min read

DMARC is one of those technologies that looks simple on paper — publish a DNS TXT record, done — and then proceeds to consume weeks of your life as you discover every obscure way email can be sent from your domain. I spent the better part of a month rolling out DMARC across a platform with dozens of domains, hundreds of thousands of daily emails, and every possible combination of Google Workspace, AWS SES, Postfix relays, and third-party services. This is everything I learned.

This isn’t a “what is DMARC” overview. This is the field manual — the theory you need to understand why things break, the practical steps to fix them, and the gotchas that no RFC will warn you about.

The Three Pillars: SPF, DKIM, and DMARC

Email authentication is a stack of three protocols, each solving a different piece of the trust problem. Understanding how they interact — and where they diverge — is the foundation for everything else.

SPF: Who Is Allowed to Send

SPF (Sender Policy Framework, RFC 7208) lets a domain declare which IP addresses are authorized to send mail on its behalf. It’s a DNS TXT record at the domain apex:

v=spf1 include:_spf.google.com include:amazonses.com ip4:107.21.203.170 -all

When a receiving server gets an email, it checks the envelope sender (the MAIL FROM in the SMTP transaction, not the From header the user sees) and looks up the SPF record for that domain. If the sending IP matches, SPF passes.

SPF has a critical limitation: it checks the envelope sender, not the From header. These are often different. A transactional email service might use bounce-id@ses.example.com as the envelope sender while the From header says noreply@yourcompany.com. SPF passes for ses.example.com, but that’s not your domain — so it doesn’t help with DMARC alignment (more on that shortly).

SPF's 10-lookup limit

SPF records are evaluated recursively: each include: triggers another DNS lookup, and those includes can have their own includes. RFC 7208 caps this at 10 DNS lookups total. Google’s _spf.google.com alone uses 3-4. If you have multiple third-party services, you’ll hit this limit fast. The fix is to flatten your SPF record (resolve all includes to IP ranges) or split into sub-records ( include:_spf1.example.com include:_spf2.example.com).

DKIM: Cryptographic Proof of Origin

DKIM (DomainKeys Identified Mail, RFC 6376) takes a different approach: the sending server signs outgoing messages with a private key, and the receiving server verifies the signature using a public key published in DNS.

selector._domainkey.example.com TXT “v=DKIM1; k=rsa; p=MIIBIjANBgkq…”

The signature covers specific headers (From, Subject, Date, etc.) and the body hash. It’s included as a DKIM-Signature header in the message. The d= tag in the signature identifies the signing domain, and the s= tag identifies the selector (so you can rotate keys without downtime).

DKIM’s advantage over SPF is that it survives forwarding. When Gmail forwards a message to another mailbox, the SPF check fails (Gmail’s IP isn’t authorized for your domain), but the DKIM signature remains intact because the message content hasn’t changed.

The 255-character DNS TXT limit

DNS TXT records have a 255-character string limit per chunk. A 2048-bit DKIM key is ~400 characters, so it must be split into multiple strings within a single TXT record. In Terraform HCL, the escape sequence "" concatenates adjacent strings: “first255chars""remainingchars”. If you use the wrong syntax, terraform fmt will reject it.

DMARC: Tying It Together

DMARC (Domain-based Message Authentication, Reporting, and Conformance, RFC 7489) builds on SPF and DKIM by adding two things: alignment and policy.

Alignment means the domain in SPF or DKIM must match the domain in the From header. SPF alignment checks that the envelope sender’s domain matches the From header domain. DKIM alignment checks that the d= signing domain matches the From header domain. In relaxed mode (the default), organizational domain matching is enough — bounce.example.com aligns with example.com. In strict mode, the domains must match exactly.

Policy tells receivers what to do when authentication fails:

  • p=none — monitor only, deliver everything, send reports

  • p=quarantine — put failures in spam

  • p=reject — reject failures outright

A DMARC record lives at _dmarc.example.com:

_dmarc.example.com TXT “v=DMARC1; p=none; pct=100; rua=mailto:dmarc-reports@example.com;”

The rua tag is where aggregate reports get sent — XML files that show you every IP that sent mail claiming to be your domain, and whether SPF/DKIM passed or failed. This is your primary tool during rollout.

There’s also ruf — the failure report URI, where individual per-message failure reports get sent. Most organizations skip ruf because failure reports contain full message headers and raise privacy concerns. Two related tags, fo (which controls when failure reports are generated) and rf (the report format), are commonly cargo-culted into DMARC records. But per RFC 7489 section 6.3, the fo tag’s content

must be ignored if a ruf tag is not also specified

. So if your DMARC record has fo=1; rf=afrf; but no ruf, those tags are dead config doing nothing. Leave them out.

A clean, complete DMARC record for the monitoring phase looks like:

v=DMARC1; p=none; pct=100; rua=mailto:reports@aggregator.example.com,mailto:dmarc@yourdomain.com;

That’s it. v, p, pct, and rua. Everything else is either for enforcement (sp, adkim, aspf) or requires ruf to function.

Phase 1: Discovery — Start at p=none

The first rule of DMARC rollout: never start at p=reject. You don’t know who’s sending mail as your domain until you look. Publish p=none with aggregate reporting and wait.

v=DMARC1; p=none; pct=100; rua=mailto:dmarc@example.com;

Sign up for a DMARC report aggregator — services like dmarcian, MXToolbox, or Report URI parse the XML reports into readable dashboards. Point your rua at their ingestion addresses. You’ll want multiple destinations for redundancy:

rua=mailto:id@ag.dmarcian.com,mailto:id@mxtoolbox.dmarc-report.com,mailto:dmarc+rua@example.com;

Cross-domain rua authorization

If your rua addresses are on a different domain than the DMARC record, the receiving domain must publish a DNS record authorizing it. For example, if example.com’s DMARC sends reports to @dmarcian.com, dmarcian publishes example.com._report._dmarc.dmarcian.com TXT “v=DMARC1”. Most DMARC services handle this automatically. If you’re sending reports to your own alternative domain, you’ll need a wildcard authorization: *._report._dmarc.example.com TXT “v=DMARC1”.

Give it at least two weeks. The reports will reveal every source sending mail as your domain: your application servers, your transactional email service, Google Workspace, marketing platforms, third-party SaaS tools you forgot about, and — if you’re unlucky — spoofed mail from attackers. For a large platform, expect to find 10-20 legitimate sending sources across a dozen subdomains.

Phase 2: Fix Alignment Issues

Once the reports are flowing, you’ll see patterns. Here are the most common failures and how to fix them.

AWS SES: The Double Alignment Problem

Out of the box, SES sends with MAIL FROM: id@us-west-2.amazonses.com and signs DKIM with d=amazonses.com. Both SPF and DKIM pass, but neither aligns with your From header domain. DMARC fails at 0% compliance.

The fix requires two SES features:

  • DKIM signing: Enable aws_ses_domain_dkim, which generates 3 CNAME records. Once verified, SES signs with d=yourdomain.com.

  • Custom MAIL FROM: Set aws_ses_domain_mail_from to a subdomain like bounce.yourdomain.com. SES requires this to be a subdomain — you can’t use the apex. Add an MX record pointing to SES’s feedback endpoint and an SPF record authorizing SES.

With both in place, SES sends mail that aligns on both SPF and DKIM. In Terraform:

resource "aws_ses_domain_dkim" "main" {
domain   = aws_ses_domain_identity.main.domain
provider = aws.ses
}

resource "aws_route53_record" "ses_dkim" {
count   = 3
zone_id = var.zone_id
name    = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey"
type    = "CNAME"
ttl     = 300
records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
}

resource "aws_ses_domain_mail_from" "main" {
domain           = aws_ses_domain_identity.main.domain
mail_from_domain = "bounce.${aws_ses_domain_identity.main.domain}"
provider         = aws.ses
}

resource "aws_route53_record" "ses_mail_from_mx" {
zone_id = var.zone_id
name    = aws_ses_domain_mail_from.main.mail_from_domain
type    = "MX"
ttl     = 300
records = ["10 feedback-smtp.us-west-2.amazonses.com"]
}

resource "aws_route53_record" "ses_mail_from_spf" {
zone_id = var.zone_id
name    = aws_ses_domain_mail_from.main.mail_from_domain
type    = "TXT"
ttl     = 300
records = ["v=spf1 include:amazonses.com -all"]
}
SES DKIM tokens are only known after apply

The DKIM token values come from the aws_ses_domain_dkim resource, which means they’re unknown during planning. You can’t use for_each on them (Terraform requires for_each keys to be known at plan time). Stick with count = 3 — SES always generates exactly three tokens.

Google Workspace: Auto-Generated DKIM

Google Workspace signs outbound mail with DKIM by default, but uses an auto-generated key at d=yourdomain-com.20230601.gappssmtp.com. This doesn’t align with your domain for DMARC purposes.

To fix it: go to Google Admin → Apps → Google Workspace → Gmail → Authenticate email, select your domain, generate a DKIM key (2048-bit, any selector — google is conventional), publish the TXT record in DNS, then click “Start authentication.” Google will then sign with d=yourdomain.com, which aligns.

Watch out for secondary domains in Google Workspace. If you have core.company.com as an additional domain, it gets its own auto-generated DKIM key that won’t align with company.com. You need to generate and activate DKIM for each domain individually.

Postfix Bounce Messages: The Silent DMARC Killer

This one will drive you mad if you don’t know about it. Your DMARC reports show hundreds of messages with SPF: none, DKIM: none coming from your own mail servers. Everything else passes. What’s going on?

Bounce messages (Delivery Status Notifications). When Postfix generates a bounce, it sends it as MAIL FROM:<>— an empty envelope sender, per RFC 3461. SPF then checks the HELO hostname instead of your domain, which typically returns spf=none. And by default, Postfix does not pass internally generated messages (bounces, delay notifications) through content filters like OpenDKIM. So the bounce has neither SPF alignment nor a DKIM signature. DMARC fails.

The fix is a single Postfix parameter:

internal_mail_filter_classes = bounce

This tells Postfix to pass bounce messages through the non_smtpd_milters chain, which includes OpenDKIM. Once applied, bounces get DKIM-signed with your domain’s key, DKIM aligns, and DMARC passes.

You can verify it immediately. Send an email to a non-existent address to trigger a bounce:

$ sendmail nonexistent@example.com <<'EOF'
Subject: bounce test
From: test@yourdomain.com

test
EOF

$ grep 'from=<>' /var/log/mail.log | tail -1
# Find the bounce queue ID

$ grep 'QUEUE_ID' /var/log/mail.log | grep dkim
# Confirm DKIM-Signature was added

Check the bounce email headers — you should see both a DKIM-Signature on the bounce itself (with Return-Path: <>) and dmarc=pass in the receiving server’s Authentication-Results.

Backscatter risk: think before you sign bounces

DKIM-signing bounces has a security trade-off. Bounces include the original message content. If an attacker sends a forged email to your server (targeting a non-existent recipient), your server generates a bounce back to the forged sender — now containing the attacker’s payload, DKIM-signed with your domain. Your mail server becomes a laundering service for attacker-controlled content.

This is only a concern for internet-facing MTAs that accept mail from the public. If your Postfix is an outbound-only relay (application sends mail out, server never accepts inbound SMTP from the internet), there’s no attack surface — bounces are only generated from your own legitimate outbound mail. For inbound-facing servers, the mitigation is to reject unknown recipients at SMTP time ( reject_unverified_recipient) so no bounce is generated in the first place, and to restrict accepted recipients to known patterns (e.g., hash-based reply addresses) so an attacker can’t trigger a bounce to an arbitrary address.

A cleaner architectural approach is to use a dedicated subdomain for bounces — e.g., bounces.example.com — with its own DKIM key and signing infrastructure, completely separate from your customer-facing mail. The mailer that signs bounces doesn’t originate your regular communications, so it can’t be used to forge normal emails even if compromised. This also matters for reputation: mail service providers pay close attention to the source DNS domain, and isolating bounce traffic prevents it from dragging down your primary domain’s sender reputation.

Third-Party Senders

Every SaaS tool that sends email “from” your domain is a potential DMARC failure. Marketing platforms, helpdesk systems, CRM tools, monitoring alerts — each needs to be authorized via SPF (add their IPs or include their SPF record) and ideally configured with DKIM signing using your domain’s key.

Some services support custom DKIM (you provide a key pair or they give you a CNAME to publish). Others only send with their own domain in DKIM and rely on SPF alignment. Check each service’s documentation. If a service can’t do either, consider having it send from a subdomain with its own DMARC policy, or route its mail through your own relay.

Phase 3: Subdomain Strategy

DMARC applies to the organizational domain and all its subdomains. If your apex has p=none, every subdomain inherits that policy unless it publishes its own _dmarc record. The sp= tag in the apex DMARC record sets a default subdomain policy — useful for locking down subdomains that shouldn’t send mail while keeping the apex permissive during rollout.

In practice, you’ll encounter three types of subdomains:

  • Subdomains that send mail (e.g., notifications.example.com): Need proper SPF, DKIM, and possibly their own DMARC record.

  • Subdomains that don’t send mail but exist in DNS (e.g., cdn.example.com, api.example.com): Add a null SPF record (v=spf1 -all) to explicitly declare they don’t send email. This prevents spoofing and silences DMARC report noise from internal services that accidentally send cron/monitoring mail as these domains.

  • Internal infrastructure subdomains (e.g., foundation.tools.company.com): EC2 hosts and internal services often default to the machine’s domain for system mail. Either reconfigure them to use the proper sending domain or add null SPF to reject this traffic.

Phase 4: Moving to Enforcement

Once your aggregate reports show 95%+ DMARC compliance across all legitimate sources, you’re ready to enforce. Don’t jump straight to p=reject.

The pct tag is your throttle. It controls what percentage of failing messages the policy applies to:

  1. Move to p=quarantine; pct=10; — only 10% of failures go to spam. Watch the reports for a week.

  2. Ramp up: pct=25, then pct=50, then pct=100.

  3. Once quarantine at 100% looks clean, move to p=reject; pct=10; and ramp up again.

At each step, check your aggregate reports. Look for legitimate senders that suddenly show failures — they’re the ones you missed. Fix them before increasing the percentage.

The sp= tag for subdomains

You can enforce differently for subdomains. If your apex is p=quarantine but you know no subdomain should be sending mail, set sp=reject. This catches subdomain spoofing immediately while you’re still cautiously ramping up the apex policy.

Operational Gotchas

A collection of things that will bite you if you’re not watching.

SPF HELO Fallback

When the envelope sender is empty (bounces, as discussed above), SPF falls back to checking the HELO/EHLO hostname. This means your mail server’s hostname matters for SPF. If smtp-relay1.example.com announces itself in HELO and there’s no SPF record for that hostname, bounces get spf=none. You can add an SPF record for the HELO hostname as belt-and-suspenders, but DKIM signing bounces is the more reliable fix.

DNS Record Conflicts

Route53 (and most DNS providers) don’t allow two record sets of the same type at the same name. If your domain already has a TXT record (say, a site verification string), your SPF record must be in the same record set. In Terraform, that means a single aws_route53_record resource with multiple values in the records list — not two separate resources.

Importing Existing DNS Records

If you’re adding SPF to a domain that already has TXT records created outside of Terraform, you’ll need to import the existing record into state first:

terraform import aws_route53_record.spf ZONEID_example.com_TXT

Then update your Terraform config to include both the existing values and the new SPF record. Otherwise Terraform will try to create a new record and fail with a conflict.

DKIM Key Rotation

DKIM keys should be rotated periodically (yearly is common). The selector mechanism makes this seamless: publish a new key with a new selector, update your signing config to use the new selector, then remove the old key from DNS after a grace period. The grace period matters because messages in transit or in spam quarantine may still reference the old selector.

Lower TTLs During Rollout

Set DNS TTLs to 300 seconds while you’re actively making changes. If you publish a broken SPF record with a 3600s TTL, you’re stuck for an hour. At 300s, you can fix and propagate in five minutes. Bump TTLs back up once everything is validated and stable.

Multiple Domains, Same Infrastructure

If you operate multiple domains through the same mail infrastructure (same Postfix relays, same SES account), each domain needs its own SPF, DKIM, and DMARC records. The SPF records can share includes, and the DKIM signing can use domain-specific selectors on the same milter, but every domain needs its own DNS entries. There’s no shortcut here.

Defunct Third-Party Services

Audit your DMARC rua and ruf addresses periodically. Services shut down (dmarcanalyzer was acquired and discontinued), and stale addresses just silently drop reports. If your only rua destination goes dark, you’re flying blind. Use multiple report destinations.

The Checklist

For each domain that sends email:

  1. Publish a DMARC record at _dmarc.example.com with p=none and rua pointing to your report aggregator(s).

  2. Wait for aggregate reports. Identify every legitimate sending source.

  3. Fix SPF: ensure every sending IP or service is in your SPF record. Stay under 10 DNS lookups.

  4. Fix DKIM: ensure every sending service signs with d=yourdomain.com (or an aligned subdomain). Activate DKIM in Google Workspace Admin if applicable.

  5. Fix bounces: add internal_mail_filter_classes = bounce to Postfix if you’re running your own MTAs.

  6. Fix cloud services: enable DKIM and custom MAIL FROM in SES. Check other cloud providers similarly.

  7. Add null SPF (v=spf1 -all) to subdomains that don’t send email.

  8. Clean up DMARC records: remove fo and rf tags if you don’t have ruf, drop stale report addresses, remove deprecated service includes from SPF.

  9. Ramp enforcement: p=quarantine at increasing pct, then p=reject.

  10. Monitor forever. New sending sources, key rotations, infrastructure changes — DMARC isn’t set-and-forget.

Conclusion

DMARC enforcement is not a single change — it’s a project. The protocol itself is straightforward, but the real work is in discovery: finding every way email leaves your organization, understanding why each path exists, and fixing alignment one source at a time. The gotchas are in the details — bounce messages that bypass milters, SES sending with Amazon’s domain, Google Workspace using auto-generated DKIM keys, SPF records hitting the lookup limit, DNS TXT records breaking at 255 characters.

Start at p=none, be patient with the discovery phase, fix alignment methodically, and ramp enforcement gradually. Your aggregate reports are the source of truth. By the time you reach p=reject, you’ll understand your email infrastructure better than you ever wanted to.

Frequently Asked Questions

Can I go straight to p=reject without starting at p=none?

Technically yes, but you'll almost certainly break legitimate mail. Start at p=none with aggregate reporting, spend weeks (or months) identifying all senders, fix alignment issues, move to p=quarantine with a low pct, then gradually increase to p=reject. The discovery phase is where the real work happens.

Why do my bounce messages fail DMARC even though regular mail passes?

Bounce messages (DSNs) use an empty envelope sender (MAIL FROM:<>), which means SPF checks the HELO hostname instead of your domain. If your MTA doesn't pass bounces through the DKIM milter, they won't be signed either. In Postfix, set internal_mail_filter_classes=bounce to fix this.

Do I need SPF, DKIM, and DMARC, or is one enough?

DMARC requires at least one of SPF or DKIM to pass AND align with the From header domain. SPF alone breaks on forwarding. DKIM alone is more robust but requires signing infrastructure. Best practice is both SPF and DKIM, with DMARC to tie them together and get reporting.

What happens to subdomains without their own DMARC record?

They inherit the organizational domain's DMARC policy unless it specifies sp= (subdomain policy). If your apex has p=reject but a subdomain sends mail without proper authentication, those messages get rejected. Add explicit DMARC records for subdomains that send mail, or use sp=none while you sort things out.

How long should I stay at p=none before enforcing?

Until your aggregate reports show 95%+ compliance across all legitimate sources. For a large platform with dozens of sending sources, this typically takes 4-8 weeks of active work — identifying senders, fixing SPF/DKIM alignment, and handling edge cases like bounce messages and third-party services.

Laurent Goudet

CTO at Freelancer.com

AI agents, networking, and infrastructure at scale

Other deep-dives

© 2026 Laurent Goudet · Bordeaux, France · lepro.dev

vd9714f4