Authentication Amazon SES
SES passes SPF and DKIM but DMARC still fails
The authentication headers look like a pass. spf=pass. dkim=pass. And then dmarc=fail, which reads like a contradiction until you look at what SPF actually authenticated. Without a custom MAIL FROM domain, SES uses its own amazonses.com subdomain as the Return-Path, so SPF passes for that domain — not for the one in your From header. DMARC requires alignment, and those two do not align.
DMARC does not ask whether SPF passed. It asks whether SPF passed for the same domain as the From header. SES defaults the Return-Path to an amazonses.com subdomain, so SPF authenticates Amazon's domain and alignment fails.
Set a custom MAIL FROM domain with PutEmailIdentityMailFromAttributes, publish its MX and SPF records, and SPF starts authenticating a subdomain of yours, which aligns.
The problem in plain words
You set up SES, published the DKIM CNAMEs, added an SPF record, and every test tool says SPF and DKIM pass. Then a DMARC report shows failures, or Gmail puts the mail in spam and the headers say dmarc=fail.
The mail is not forged and nothing is misconfigured in the usual sense. It is an alignment problem: DMARC passes if either SPF or DKIM passes and aligns with the From domain. DKIM usually saves you here, which is why many setups look fine — until a forwarder breaks the DKIM signature and SPF is the only thing left, and SPF is aligned to Amazon.
Why it happens
The Return-Path is not the From address. SPF authenticates the envelope sender, which lives in the Return-Path, and receivers see whatever SES put there. By default that is a subdomain of amazonses.com.
Alignment is the whole point of DMARC. Anyone can pass SPF for a domain they control. DMARC asks whether the domain that passed is the domain the recipient sees, which is what makes it useful against spoofing — and what makes the default SES setup fail it on the SPF side.
DKIM masks the problem. With Easy DKIM the signature is aligned, DMARC passes on the DKIM leg, and nobody notices SPF is misaligned. The day a mailing list or forwarder rewrites the body, DKIM breaks, SPF is all that is left, and mail that worked for a year starts failing.
How to fix it
Confirm what the Return-Path actually is
Send yourself a message and look at the raw headers. If Return-Path ends in amazonses.com while From is your domain, that is the misalignment.
aws sesv2 get-email-identity --email-identity yourdomain.com \\
--query 'MailFromAttributes'
An empty result, or MailFromDomainStatus of PENDING, means it is not in effect.
Choose a subdomain, not the root
Use something like mail.yourdomain.com. The MAIL FROM domain needs its own MX record, and putting an MX on your root domain would interfere with receiving mail there. A dedicated subdomain avoids that entirely.
Set it on the identity
PutEmailIdentityMailFromAttributes takes the subdomain and a behaviour for when the records are missing. USE_DEFAULT_VALUE falls back to the amazonses.com domain if DNS is not ready, which keeps mail flowing; REJECT_MESSAGE fails the send instead. Start with the former.
Publish the two DNS records
The subdomain needs an MX pointing at the SES inbound endpoint for your region, and a TXT SPF record containing include:amazonses.com. SES reports SUCCESS once it can see both.
How to check it worked
Check the status is SUCCESS, then read the headers of a real message:
aws sesv2 get-email-identity --email-identity yourdomain.com \\
--query 'MailFromAttributes.MailFromDomainStatus'
# SUCCESS
In the received message, Return-Path should now be at mail.yourdomain.com, and Authentication-Results should show spf=pass with that domain plus dmarc=pass.
The full code
The script reports the MAIL FROM state for every identity, flags the ones still defaulting to amazonses.com, and can set a subdomain on one. It prints the DNS records you then need to publish, because that half cannot be done from the SES API.
"""Report SES identities whose Return-Path is not aligned with the From domain.
DMARC passes only if SPF or DKIM passes AND aligns with the From domain. Without a
custom MAIL FROM, SES uses an amazonses.com subdomain, so the SPF leg never aligns
and DMARC rests entirely on DKIM -- which breaks the first time a forwarder rewrites
the message.
"""
import argparse
import logging
import sys
import boto3
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ses_mail_from_audit")
def alignment_problem(identity, attrs):
"""Pure decision function over GetEmailIdentity's MailFromAttributes.
Three distinct states matter, and only one of them is fine.
"""
domain = (attrs or {}).get("MailFromDomain")
status = (attrs or {}).get("MailFromDomainStatus")
if not domain:
return f"{identity}: no custom MAIL FROM, so SPF aligns to amazonses.com and DMARC rests on DKIM alone"
if status != "SUCCESS":
return f"{identity}: MAIL FROM {domain} is {status}, so SES is still using the default"
if not domain.endswith(identity) and identity not in domain:
return f"{identity}: MAIL FROM {domain} is not a subdomain of the identity, so it does not align"
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--region", default="us-east-1")
ap.add_argument("--set-on", help="identity to configure")
ap.add_argument("--subdomain", help="e.g. mail.yourdomain.com")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
ses = boto3.client("sesv2", region_name=args.region)
problems = 0
for ident in ses.list_email_identities().get("EmailIdentities", []):
name = ident["IdentityName"]
detail = ses.get_email_identity(EmailIdentity=name)
problem = alignment_problem(name, detail.get("MailFromAttributes"))
if problem:
problems += 1
log.error(problem)
else:
log.info("%s: MAIL FROM aligned", name)
if args.set_on and args.subdomain:
if args.apply:
ses.put_email_identity_mail_from_attributes(
EmailIdentity=args.set_on,
MailFromDomain=args.subdomain,
BehaviorOnMxFailure="USE_DEFAULT_VALUE",
)
log.info("set MAIL FROM %s on %s", args.subdomain, args.set_on)
else:
log.info("WOULD set MAIL FROM %s on %s -- pass --apply", args.subdomain, args.set_on)
log.info("now publish, in the %s zone:", args.subdomain)
log.info(" MX 10 feedback-smtp.%s.amazonses.com", args.region)
log.info(' TXT "v=spf1 include:amazonses.com ~all"')
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report SES identities whose Return-Path is not aligned with the From domain.
*
* DMARC passes only if SPF or DKIM passes AND aligns with the From domain. Without
* a custom MAIL FROM, SES uses an amazonses.com subdomain, so the SPF leg never
* aligns and DMARC rests entirely on DKIM.
*/
import {
SESv2Client,
ListEmailIdentitiesCommand,
GetEmailIdentityCommand,
PutEmailIdentityMailFromAttributesCommand,
} from '@aws-sdk/client-sesv2';
/**
* Pure decision function over GetEmailIdentity's MailFromAttributes.
* Three distinct states matter, and only one of them is fine.
*/
export function alignmentProblem(identity, attrs) {
const domain = attrs?.MailFromDomain;
const status = attrs?.MailFromDomainStatus;
if (!domain) {
return `${identity}: no custom MAIL FROM, so SPF aligns to amazonses.com and DMARC rests on DKIM alone`;
}
if (status !== 'SUCCESS') {
return `${identity}: MAIL FROM ${domain} is ${status}, so SES is still using the default`;
}
if (!domain.endsWith(identity) && !domain.includes(identity)) {
return `${identity}: MAIL FROM ${domain} is not a subdomain of the identity, so it does not align`;
}
return null;
}
async function main() {
const region = process.env.AWS_REGION ?? 'us-east-1';
const apply = process.argv.includes('--apply');
const setOn = process.argv[process.argv.indexOf('--set-on') + 1];
const subdomain = process.argv[process.argv.indexOf('--subdomain') + 1];
const ses = new SESv2Client({ region });
let problems = 0;
const { EmailIdentities = [] } = await ses.send(new ListEmailIdentitiesCommand({}));
for (const ident of EmailIdentities) {
const name = ident.IdentityName;
const detail = await ses.send(new GetEmailIdentityCommand({ EmailIdentity: name }));
const problem = alignmentProblem(name, detail.MailFromAttributes);
if (problem) { problems += 1; console.error(problem); }
else console.log(`${name}: MAIL FROM aligned`);
}
if (process.argv.includes('--set-on') && subdomain) {
if (apply) {
await ses.send(new PutEmailIdentityMailFromAttributesCommand({
EmailIdentity: setOn, MailFromDomain: subdomain,
BehaviorOnMxFailure: 'USE_DEFAULT_VALUE',
}));
console.log(`set MAIL FROM ${subdomain} on ${setOn}`);
} else {
console.log(`WOULD set MAIL FROM ${subdomain} on ${setOn} -- pass --apply`);
}
console.log(`now publish, in the ${subdomain} zone:`);
console.log(` MX 10 feedback-smtp.${region}.amazonses.com`);
console.log(' TXT "v=spf1 include:amazonses.com ~all"');
}
process.exit(problems ? 1 : 0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
Three states look similar and mean different things: no MAIL FROM at all, one that is set but still pending, and one set to a domain that does not actually align. The test separates them.
from ses_mail_from_audit import alignment_problem
def test_no_mail_from_is_a_problem():
assert "amazonses.com" in alignment_problem("example.com", None)
def test_empty_attributes_are_treated_as_absent():
assert alignment_problem("example.com", {}) is not None
def test_pending_status_is_still_a_problem():
"""SES has not verified the DNS yet, so it is still using the default."""
out = alignment_problem("example.com",
{"MailFromDomain": "mail.example.com",
"MailFromDomainStatus": "PENDING"})
assert "PENDING" in out
def test_aligned_subdomain_is_clean():
assert alignment_problem("example.com",
{"MailFromDomain": "mail.example.com",
"MailFromDomainStatus": "SUCCESS"}) is None
def test_unrelated_domain_does_not_align():
out = alignment_problem("example.com",
{"MailFromDomain": "mail.other.net",
"MailFromDomainStatus": "SUCCESS"})
assert "does not align" in out
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { alignmentProblem } from './ses-mail-from-audit.mjs';
test('no MAIL FROM is a problem', () => {
assert.match(alignmentProblem('example.com', undefined), /amazonses\.com/);
});
test('pending status is still a problem', () => {
const out = alignmentProblem('example.com',
{ MailFromDomain: 'mail.example.com', MailFromDomainStatus: 'PENDING' });
assert.match(out, /PENDING/);
});
test('an aligned subdomain is clean', () => {
assert.equal(alignmentProblem('example.com',
{ MailFromDomain: 'mail.example.com', MailFromDomainStatus: 'SUCCESS' }), null);
});
test('an unrelated domain does not align', () => {
const out = alignmentProblem('example.com',
{ MailFromDomain: 'mail.other.net', MailFromDomainStatus: 'SUCCESS' });
assert.match(out, /does not align/);
});
FAQ
How can SPF pass and DMARC still fail?
DMARC does not ask whether SPF passed. It asks whether SPF passed for the same domain that appears in the From header. SES defaults the Return-Path to an amazonses.com subdomain, so SPF passes for Amazon's domain, which does not align with yours.
Why does it work today if SPF is misaligned?
Because DMARC passes if either SPF or DKIM aligns, and Easy DKIM aligns. You are relying entirely on the DKIM leg. The day a mailing list or forwarder modifies the message, the DKIM signature breaks and there is nothing left to pass.
Why a subdomain rather than the root domain?
The MAIL FROM domain requires its own MX record. Putting one on your root domain would interfere with receiving mail there. A dedicated subdomain such as mail.yourdomain.com keeps the two separate.
What does BehaviorOnMxFailure change?
USE_DEFAULT_VALUE falls back to the amazonses.com domain if the MX record is missing, so mail keeps flowing while DNS propagates. REJECT_MESSAGE fails the send instead. Start with the former and tighten later if you want the stricter guarantee.
Do I still need my own SPF record on the root domain?
Yes, for the From domain. The MAIL FROM subdomain needs its own SPF record too, containing include:amazonses.com. They serve different checks and both matter.
Related field notes
- DMARC stuck at p=none and never enforcing
- SPF exceeds the 10 DNS lookup limit
- SES bounces are invisible with no event destination
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Using a custom MAIL FROM domain — AWS docs
- Complying with DMARC authentication protocol in Amazon SES — AWS docs
- boto3 sesv2 put_email_identity_mail_from_attributes
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.