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 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.
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 reportsp=quarantine— put failures in spamp=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;
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 withd=yourdomain.com.Custom MAIL FROM: Set
aws_ses_domain_mail_fromto a subdomain likebounce.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"]
}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 = bounceThis 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 addedCheck 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.
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:
Move to
p=quarantine; pct=10;— only 10% of failures go to spam. Watch the reports for a week.Ramp up:
pct=25, thenpct=50, thenpct=100.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.
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_TXTThen 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:
Publish a DMARC record at
_dmarc.example.comwithp=noneandruapointing to your report aggregator(s).Wait for aggregate reports. Identify every legitimate sending source.
Fix SPF: ensure every sending IP or service is in your SPF record. Stay under 10 DNS lookups.
Fix DKIM: ensure every sending service signs with
d=yourdomain.com(or an aligned subdomain). Activate DKIM in Google Workspace Admin if applicable.Fix bounces: add
internal_mail_filter_classes = bounceto Postfix if you’re running your own MTAs.Fix cloud services: enable DKIM and custom MAIL FROM in SES. Check other cloud providers similarly.
Add null SPF (
v=spf1 -all) to subdomains that don’t send email.Clean up DMARC records: remove
foandrftags if you don’t haveruf, drop stale report addresses, remove deprecated service includes from SPF.Ramp enforcement:
p=quarantineat increasingpct, thenp=reject.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.
Other deep-dives
TLS vs mTLS Handshake
Comparing standard and mutual TLS authentication flows
Network SecurityTLS 1.2 vs TLS 1.3 Handshake
Comparing handshake efficiency and security improvements
Network EngineeringIPv6-Only Network with NAT64/464XLAT
Running an IPv6-only local network while maintaining IPv4 internet connectivity
CDN EngineeringThe Fastly VCL == false Trap
How using == false instead of ! in Fastly VCL compound conditions can silently break your logic
AI & IndustrySomething Big Is Happening — But It's Not What You Think
Why AI is an abstraction layer shift, not an extinction event — a practitioner's response to the viral AI essay
AI & IndustryAI Agent Orchestration at Scale — What Actually Works in Production
Patterns and hard lessons from running multi-agent systems at 80M+ user scale: routing, fallback chains, context management, and why most agent architectures fail.
Network SecurityDNSSEC: Chain of Trust from Root to This Domain
How DNSSEC builds a cryptographic chain of trust from the DNS root to this zone — with Pulumi setup and live dig verification
Cloud SecurityYour Google Maps API Key Can Now Drain Your Bank Account
Google silently changed API key permissions so that keys meant for Maps can now call Gemini AI. Here's how to audit your GCP projects and lock down exposed keys before someone else finds them.