Check SES Domains for DKIM, SPF and DMARC

A laptop screen showing an email inbox in a dimly lit room

Photo by Justin Morgan on Unsplash

An SES DKIM, SPF and DMARC check reads each domain identity with the SES v2 GetEmailIdentity call, confirms that DKIM status is SUCCESS with signing on, looks up the SPF record on the custom MAIL FROM domain, and reads the _dmarc TXT record. A message passes DMARC only if DKIM or SPF passes and aligns with the From domain.

A domain can show as verified in the SES console and still fail DMARC at Gmail or Yahoo. The usual causes are a DKIM record that was never published, no custom MAIL FROM domain, or no DMARC record at all. This example is for engineers who send mail through Amazon SES and want an SES DKIM, SPF and DMARC check across every Region and identity in one pass.

You get a TypeScript script for the AWS SDK for JavaScript v3 that reports each domain’s DKIM status, MAIL FROM setup, SPF record, DMARC policy and whether aligned authentication is possible. It only reads SES settings and public DNS. If you haven’t sent mail from code yet, start with the guide to send email with Amazon SES and AWS SDK v3 in TypeScript.

How do DKIM, SPF and DMARC work together in SES?

DMARC checks that the domain in the visible From address matches a domain that passed SPF or DKIM. The SES developer guide describes two ways to get there:

  • DKIM alignment. With Easy DKIM, SES signs every message with your domain, so the DKIM domain matches the From domain. This is the path most SES senders rely on.
  • SPF alignment. SPF checks the MAIL FROM (envelope sender) domain. By default SES uses a subdomain of amazonses.com, so SPF passes but can’t align with your From domain. You need a custom MAIL FROM subdomain, such as bounce.example.com, with an MX record pointing to feedback-smtp.<region>.amazonses.com and the TXT record "v=spf1 include:amazonses.com ~all". SES also notes that SPF alignment fails if your DMARC record sets strict SPF alignment with aspf=s.

A message passes DMARC if either path passes. AWS recommends setting up both, because forwarding breaks SPF while a DKIM signature usually survives it.

The DMARC policy tag p= tells receivers what to do with mail that fails: none (monitor only), quarantine (spam folder) or reject. SES recommends starting at p=none with an rua= address for aggregate reports, then tightening. DMARC itself was republished in May 2026 as RFC 9989, the DMARC standards-track specification, which obsoletes RFC 7489 and removes the old pct tag.

Do Gmail and Yahoo require DMARC for SES senders?

Google’s Gmail email sender guidelines have applied since 1 February 2024. Every sender needs SPF or DKIM. Senders of more than 5,000 messages a day to Gmail accounts need SPF and DKIM, a DMARC record (a policy of none is enough), and alignment between the From domain and either the SPF or the DKIM domain. The script checks exactly those points for each SES domain.

What does the script check?

  1. Lists identities in each RegionpaginateListEmailIdentities returns every identity. Email address identities are counted and skipped, because DKIM and DMARC belong to the domain.
  2. Reads DKIM and MAIL FROMGetEmailIdentity returns DkimAttributes.Status (PENDING, SUCCESS, FAILED, TEMPORARY_FAILURE or NOT_STARTED), SigningEnabled, and MailFromAttributes with the MAIL FROM domain and its status.
  3. Looks up SPFWith node:dns/promises, it reads TXT records on the MAIL FROM domain and flags a missing record, more than one v=spf1 record, or one that doesn’t include amazonses.com.
  4. Looks up DMARCIt reads _dmarc.<domain>, then each parent domain, and uses the parent’s sp= for subdomains when present. It flags p=none, a missing rua= and aspf=s.
  5. Decides whether DMARC can passAligned DKIM (signing on and SUCCESS) or aligned SPF (custom MAIL FROM working, SPF record correct, not strict) must exist. If neither does, the row says so.

Prerequisites

  • Node.js 18 or later, npm, tsx and @aws-sdk/client-sesv2.
  • A read-only AWS profile; the guide to AWS SDK v3 credential providers such as fromIni and fromSSO covers how the SDK picks it up.
  • A DNS resolver that can reach public DNS. The script uses the machine’s resolver, so a split-horizon DNS server can give different answers than the internet sees.

Which IAM permissions does it need?

ses-email-authentication-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListSesIdentities",
      "Effect": "Allow",
      "Action": "ses:ListEmailIdentities",
      "Resource": "*"
    },
    {
      "Sid": "ReadSesIdentities",
      "Effect": "Allow",
      "Action": "ses:GetEmailIdentity",
      "Resource": "arn:aws:ses:*:123456789012:identity/*"
    }
  ]
}

DNS lookups need no AWS permissions. To build the policy for your own variant of the script, paste it into the free IAM policy generator for TypeScript code.

The script for an SES DKIM, SPF and DMARC check

check-ses-email-authentication.ts

// check-ses-email-authentication.ts
// Reports DKIM, custom MAIL FROM, SPF and DMARC status for every SES domain identity,
// and whether a message from that domain can pass DMARC through aligned DKIM or SPF.
// Report only: it reads SES settings and public DNS, and changes nothing.
// Usage:
//   npx tsx check-ses-email-authentication.ts [--regions us-east-1,eu-west-1]
import dns from "node:dns/promises";
import { GetEmailIdentityCommand, SESv2Client, paginateListEmailIdentities } from "@aws-sdk/client-sesv2";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);

interface Row {
  Region: string;
  Domain: string;
  DKIM: string;
  MailFrom: string;
  SPF: string;
  DMARC: string;
  Findings: string;
}

// TXT lookup that returns [] when the name or record doesn't exist.
async function txt(name: string): Promise<string[]> {
  try {
    const records = await dns.resolveTxt(name);
    return records.map((chunks) => chunks.join(""));
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENODATA" || code === "ENOTFOUND") return [];
    throw err;
  }
}

// Looks for _dmarc on the domain, then on each parent (a simple stand-in for the organizational domain).
async function findDmarc(domain: string): Promise<{ at: string; record: string } | undefined> {
  const labels = domain.split(".");
  for (let i = 0; i <= labels.length - 2; i++) {
    const name = labels.slice(i).join(".");
    const found = (await txt(`_dmarc.${name}`)).filter((r) => /^v=DMARC1\b/i.test(r.trim()));
    if (found.length > 0) return { at: name, record: found[0] };
  }
  return undefined;
}

function dmarcTags(record: string): Map<string, string> {
  const tags = new Map<string, string>();
  for (const part of record.split(";")) {
    const [key, ...rest] = part.split("=");
    if (key && rest.length > 0) tags.set(key.trim().toLowerCase(), rest.join("=").trim().toLowerCase());
  }
  return tags;
}

async function checkDomain(ses: SESv2Client, region: string, domain: string): Promise<Row> {
  const findings: string[] = [];
  const identity = await ses.send(new GetEmailIdentityCommand({ EmailIdentity: domain }));

  // DKIM: Easy DKIM signs with the identity's own domain, so SUCCESS plus signing on means aligned DKIM.
  const dkimStatus = identity.DkimAttributes?.Status ?? "NOT_STARTED";
  const signing = identity.DkimAttributes?.SigningEnabled === true;
  const dkimAligned = signing && dkimStatus === "SUCCESS";
  if (dkimStatus !== "SUCCESS") findings.push(`DKIM ${dkimStatus}`);
  if (!signing) findings.push("DKIM signing turned off");

  // Custom MAIL FROM: without it SES uses a subdomain of amazonses.com, so SPF can't align with your From domain.
  const mailFrom = identity.MailFromAttributes?.MailFromDomain ?? "";
  const mailFromStatus = identity.MailFromAttributes?.MailFromDomainStatus ?? "";
  let spf = "n/a (default MAIL FROM)";
  let spfPossible = false;
  if (!mailFrom) {
    findings.push("no custom MAIL FROM, SPF can't align");
  } else {
    if (mailFromStatus !== "SUCCESS") findings.push(`MAIL FROM ${mailFromStatus || "unknown"}`);
    const spfRecords = (await txt(mailFrom)).filter((r) => /^v=spf1\b/i.test(r.trim()));
    if (spfRecords.length === 0) {
      spf = "missing";
      findings.push(`no SPF record on ${mailFrom}`);
    } else if (spfRecords.length > 1) {
      spf = `${spfRecords.length} records`;
      findings.push("more than one SPF record (SPF permerror)");
    } else if (!/\binclude:amazonses\.com\b/i.test(spfRecords[0])) {
      spf = "no amazonses.com";
      findings.push("SPF record doesn't include amazonses.com");
    } else {
      spf = "ok";
      spfPossible = mailFromStatus === "SUCCESS";
    }
  }

  // DMARC: record on the domain or a parent; a parent's sp= applies to subdomains when present.
  let dmarc = "missing";
  const found = await findDmarc(domain);
  let spfAligned = false;
  if (!found) {
    findings.push("no DMARC record");
  } else {
    const tags = dmarcTags(found.record);
    const policy = (found.at !== domain ? tags.get("sp") : undefined) ?? tags.get("p") ?? "?";
    dmarc = found.at === domain ? `p=${policy}` : `p=${policy} (from ${found.at})`;
    if (policy === "none") findings.push("DMARC monitoring only (p=none)");
    if (!tags.get("rua")) findings.push("no rua= for aggregate reports");
    spfAligned = spfPossible && tags.get("aspf") !== "s";
    if (spfPossible && !spfAligned) findings.push("aspf=s blocks SPF alignment through SES");
  }
  if (!dkimAligned && !spfAligned) findings.push("DMARC fails: no aligned DKIM or SPF");

  return {
    Region: region,
    Domain: domain,
    DKIM: `${dkimStatus}${signing ? "" : " (signing off)"}`,
    MailFrom: mailFrom ? `${mailFrom} ${mailFromStatus}` : "default",
    SPF: spf,
    DMARC: dmarc,
    Findings: findings.join("; ") || "ok",
  };
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  let skippedAddresses = 0;
  for (const region of regions) {
    const ses = new SESv2Client({ region });
    try {
      for await (const page of paginateListEmailIdentities({ client: ses }, {})) {
        for (const id of page.EmailIdentities ?? []) {
          if (id.IdentityType !== "DOMAIN" || !id.IdentityName) {
            skippedAddresses++;
            continue;
          }
          rows.push(await checkDomain(ses, region, id.IdentityName));
        }
      }
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  console.log(`SES domain identities checked: ${rows.length} (email address identities skipped: ${skippedAddresses})`);
  if (rows.length > 0) console.table(rows);
  console.log("Report only. Nothing was changed in SES or DNS.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

The loop over ListEmailIdentities uses the SDK’s built-in paginator; the guide to paginate any AWS API with AWS SDK v3 paginators explains the pattern.

How do you run it?

Terminal

npm install @aws-sdk/client-sesv2
npm install --save-dev tsx typescript @types/node

# Check every SES domain identity in two Regions
AWS_PROFILE=readonly npx tsx check-ses-email-authentication.ts --regions us-east-1,eu-west-1

Sample output

Output

SES domain identities checked: 2 (email address identities skipped: 1)
┌─────────┬─────────────┬────────────────────┬───────────┬──────────────────────────────┬───────────────────────────┬─────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Domain             │ DKIM      │ MailFrom                     │ SPF                       │ DMARC                       │ Findings                                                                                                                                                 │
├─────────┼─────────────┼────────────────────┼───────────┼──────────────────────────────┼───────────────────────────┼─────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'example.com'      │ 'SUCCESS' │ 'bounce.example.com SUCCESS' │ 'ok'                      │ 'p=quarantine'              │ 'ok'                                                                                                                                                     │
│ 1       │ 'us-east-1' │ 'mail.example.org' │ 'PENDING' │ 'default'                    │ 'n/a (default MAIL FROM)' │ 'p=none (from example.org)' │ "DKIM PENDING; no custom MAIL FROM, SPF can't align; DMARC monitoring only (p=none); no rua= for aggregate reports; DMARC fails: no aligned DKIM or SPF" │
└─────────┴─────────────┴────────────────────┴───────────┴──────────────────────────────┴───────────────────────────┴─────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Report only. Nothing was changed in SES or DNS.

Domains are illustrative. example.com passes both ways. mail.example.org inherits a monitoring-only policy from its parent, its DKIM CNAME records aren’t visible yet, and it uses the default MAIL FROM, so nothing it sends can pass DMARC today.

How do you fix each finding?

  • DKIM PENDING or FAILED. Publish the three CNAME records SES shows for Easy DKIM. The SDK documents that SES searches DNS for them for up to 72 hours. If the domain lives in Route 53, check the records went into the hosted zone that’s actually delegated; the script to find unused Route 53 hosted zones often turns up a stray duplicate zone.
  • No custom MAIL FROM. Add a subdomain you don’t send or receive mail on, publish exactly one MX record and the SPF TXT record. SES tries to detect the MX record for 72 hours before marking the setup FAILED.
  • MAIL FROM FAILED. With the USE_DEFAULT_VALUE behavior, SES falls back to amazonses.com; with REJECT_MESSAGE, sends fail with MailFromDomainNotVerified. Fix the MX record and restart the setup.
  • No DMARC record or p=none. Publish v=DMARC1; p=none; rua=mailto:..., read the aggregate reports for a few weeks, then move to quarantine and later reject.
  • More than one SPF record. Merge them into one. Under RFC 7208, multiple SPF records on one name are a permanent error, so receivers treat SPF as failed.

Bounces and complaints are the other half of deliverability. Route them to a topic with the guide to publish an SNS message with AWS SDK v3, and check who receives them with the script to find risky SNS subscriptions and ones pending confirmation.

Troubleshooting

  • SPF or DMARC shows missing, but the record exists. DNS changes can take time to reach your resolver, and some providers need the quotes around the SPF value. Compare with dig TXT _dmarc.example.com.
  • A Region shows no identities. SES identities are Regional. Pass every Region you send from with --regions.
  • AccessDeniedException. The profile lacks ses:ListEmailIdentities or ses:GetEmailIdentity; the guide to troubleshoot AWS IAM access denied errors step by step helps find the blocking policy.
  • Wrong DMARC parent for a domain like example.co.uk. The script walks up labels one at a time instead of using the public suffix list, so it can query _dmarc.co.uk. That lookup finds nothing and is harmless, but check the organizational domain by hand in that case.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which SES domains in us-east-1 don’t have DKIM verified?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result, one profile and Region per session. For SPF and DMARC, keep the script: those answers come from DNS, not from the SES API. More questions like this are on the ChatWithCloud use cases for AWS teams page, and the ChatWithCloud security model explains what data leaves your machine.

Frequently asked questions

How do I check if DKIM is enabled in Amazon SES?

Run aws sesv2 get-email-identity --email-identity example.com and look at DkimAttributes. Status should be SUCCESS and SigningEnabled should be true.

Do I need a custom MAIL FROM domain in SES for DMARC?

Not if DKIM is set up, because aligned DKIM is enough to pass. A custom MAIL FROM domain adds SPF alignment as a second path, which helps when DKIM fails.

What SPF record does Amazon SES need?

For a custom MAIL FROM domain, SES asks for the TXT record "v=spf1 include:amazonses.com ~all" on that subdomain, plus one MX record pointing to feedback-smtp.<region>.amazonses.com.

Is p=none enough for Gmail bulk sender rules?

Google’s sender guidelines say the DMARC enforcement policy for senders of more than 5,000 messages a day can be set to none, as long as SPF and DKIM are set up and one of them aligns with the From domain.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud