Diagnostic Amazon SES

SES identity still shows verified but its DKIM records have drifted

Someone moved the DNS to a new provider and exported the old zone to do it. The export missed the three Easy DKIM CNAMEs, because they are long, look like machine noise, and nobody remembered what they were for. SES kept reporting the identity as verified for a while, mail kept sending, and the only visible change was that DMARC reports slowly filled with failures nobody was reading.

SES identity API Python and Node.js Detect through the API
The short answer

SES tracks two things separately: whether the identity is verified for sending and whether DKIM signing is working. A domain can read verified while its DKIM tokens no longer resolve, and mail keeps going out unsigned.

GetEmailIdentity exposes DkimAttributes.Status and the tokens. Compare those tokens against live DNS and you catch the drift before DMARC does.

The problem in plain words

Nothing errors. Sends succeed, the console shows a verified domain, and for a while DKIM may still show as successful because SES caches the last good state. Meanwhile the CNAMEs that make signing possible are gone, so messages go out without a valid signature.

The consequence is delayed and indirect: DMARC then rests on SPF alone, which — if you have not set a custom MAIL FROM — does not align either. Mail that authenticated fine for a year starts landing in spam, and the change that caused it was a DNS migration weeks earlier.

Why it happens

Easy DKIM tokens look disposable. Three CNAMEs with random-looking names pointing at dkim.amazonses.com. In a zone export, or a manual rebuild at a new registrar, they are the records most likely to be dropped as noise.

Verification and signing are different checks. Identity verification can be satisfied by a TXT record or by the DKIM records depending on how it was set up, so one can survive while the other does not.

The failure is silent by construction. An unsigned message is still a valid message. Nothing rejects it at send time. The only signal is in the receiving side's authentication results and in DMARC aggregate reports, and neither is somewhere anyone looks daily.

How to fix it

Ask SES what it thinks the tokens are

GetEmailIdentity returns the DKIM status and, for Easy DKIM, the three tokens SES expects to find:

aws sesv2 get-email-identity --email-identity yourdomain.com \\
  --query '{Verified:VerifiedForSendingStatus,Dkim:DkimAttributes}'

Resolve each token against live DNS

For each token, <token>._domainkey.yourdomain.com must be a CNAME to <token>.dkim.amazonses.com. Resolving them yourself is the part SES cannot do for you on demand, and it is what turns 'probably fine' into a definite answer.

Republish anything missing

The tokens do not change when records go missing, so republishing the same three CNAMEs restores signing. If the identity was deleted and recreated the tokens will differ, and every one has to be republished.

Run the check on a schedule

This is a drift problem, so a one-off check has a short shelf life. Running it weekly catches the next migration, the next registrar move, and the next well-meaning zone cleanup.

How to check it worked

Resolve one token by hand and confirm SES agrees:

dig +short abcdefg._domainkey.yourdomain.com CNAME
# abcdefg.dkim.amazonses.com.

aws sesv2 get-email-identity --email-identity yourdomain.com \\
  --query 'DkimAttributes.Status'
# SUCCESS

Then send a message and check the received headers show dkim=pass with your domain.

The full code

The script asks SES for the expected DKIM tokens, resolves each one against live DNS, and reports any that are missing or point somewhere unexpected. It is read-only: publishing DNS records is your DNS provider's job, so it prints exactly what to add.

ses_dkim_drift_check.py
"""Detect SES identities whose DKIM CNAMEs no longer resolve.

SES can report an identity as verified while DKIM signing has quietly stopped,
because the two are tracked separately. Mail then goes out unsigned and DMARC
starts failing weeks after whatever DNS change caused it.

Read-only. It prints the records to republish rather than writing DNS.
"""
import argparse
import logging
import sys

import boto3
import dns.resolver

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ses_dkim_drift_check")


def expected_cname(token, identity):
    """The record SES needs to exist for one Easy DKIM token."""
    return f"{token}._domainkey.{identity}", f"{token}.dkim.amazonses.com"


def resolve_cname(name):
    try:
        answers = dns.resolver.resolve(name, "CNAME")
        return str(answers[0].target).rstrip(".")
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        return None


def check_identity(identity, dkim, resolver=resolve_cname):
    """Pure-ish decision function: the DNS lookup is injected so tests can fake it."""
    problems = []
    status = (dkim or {}).get("Status")
    tokens = (dkim or {}).get("Tokens") or []
    if status != "SUCCESS":
        problems.append(f"{identity}: SES reports DKIM status {status}")
    if not tokens:
        problems.append(f"{identity}: no DKIM tokens; signing is not configured")
        return problems
    for token in tokens:
        name, want = expected_cname(token, identity)
        got = resolver(name)
        if got is None:
            problems.append(f"{identity}: {name} does not resolve; republish CNAME -> {want}")
        elif got.rstrip(".") != want:
            problems.append(f"{identity}: {name} points at {got}, expected {want}")
    return problems


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    args = ap.parse_args()

    ses = boto3.client("sesv2", region_name=args.region)
    failed = False
    for ident in ses.list_email_identities().get("EmailIdentities", []):
        name = ident["IdentityName"]
        if ident.get("IdentityType") != "DOMAIN":
            continue
        detail = ses.get_email_identity(EmailIdentity=name)
        problems = check_identity(name, detail.get("DkimAttributes"))
        for p in problems:
            failed = True
            log.error(p)
        if not problems:
            log.info("%s: DKIM signing intact", name)
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
ses-dkim-drift-check.mjs
/**
 * Detect SES identities whose DKIM CNAMEs no longer resolve.
 *
 * SES can report an identity as verified while DKIM signing has quietly stopped,
 * because the two are tracked separately. Read-only: it prints the records to
 * republish rather than writing DNS.
 */
import { promises as dns } from 'node:dns';
import {
  SESv2Client,
  ListEmailIdentitiesCommand,
  GetEmailIdentityCommand,
} from '@aws-sdk/client-sesv2';

export function expectedCname(token, identity) {
  return {
    name: `${token}._domainkey.${identity}`,
    want: `${token}.dkim.amazonses.com`,
  };
}

async function resolveCname(name) {
  try {
    const [target] = await dns.resolveCname(name);
    return target ?? null;
  } catch {
    return null;
  }
}

/** Pure-ish decision function: the DNS lookup is injected so tests can fake it. */
export async function checkIdentity(identity, dkim, resolver = resolveCname) {
  const problems = [];
  const status = dkim?.Status;
  const tokens = dkim?.Tokens ?? [];
  if (status !== 'SUCCESS') problems.push(`${identity}: SES reports DKIM status ${status}`);
  if (!tokens.length) {
    problems.push(`${identity}: no DKIM tokens; signing is not configured`);
    return problems;
  }
  for (const token of tokens) {
    const { name, want } = expectedCname(token, identity);
    const got = await resolver(name);
    if (got === null) {
      problems.push(`${identity}: ${name} does not resolve; republish CNAME -> ${want}`);
    } else if (got.replace(/\.$/, '') !== want) {
      problems.push(`${identity}: ${name} points at ${got}, expected ${want}`);
    }
  }
  return problems;
}

async function main() {
  const ses = new SESv2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
  let failed = false;
  const { EmailIdentities = [] } = await ses.send(new ListEmailIdentitiesCommand({}));
  for (const ident of EmailIdentities) {
    if (ident.IdentityType !== 'DOMAIN') continue;
    const detail = await ses.send(new GetEmailIdentityCommand({ EmailIdentity: ident.IdentityName }));
    const problems = await checkIdentity(ident.IdentityName, detail.DkimAttributes);
    problems.forEach((p) => { failed = true; console.error(p); });
    if (!problems.length) console.log(`${ident.IdentityName}: DKIM signing intact`);
  }
  process.exit(failed ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The DNS lookup is injected, so the whole rule — missing record, wrong target, no tokens at all — is testable offline with a fake resolver.

test_ses_dkim_drift_check.py
from ses_dkim_drift_check import check_identity, expected_cname

DKIM_OK = {"Status": "SUCCESS", "Tokens": ["aaa", "bbb", "ccc"]}


def resolver_all_good(name):
    token = name.split("._domainkey.")[0]
    return f"{token}.dkim.amazonses.com"


def resolver_nothing(_name):
    return None


def test_expected_cname_shape():
    name, want = expected_cname("aaa", "example.com")
    assert name == "aaa._domainkey.example.com"
    assert want == "aaa.dkim.amazonses.com"


def test_all_records_present_is_clean():
    assert check_identity("example.com", DKIM_OK, resolver_all_good) == []


def test_missing_records_are_reported_with_the_fix():
    problems = check_identity("example.com", DKIM_OK, resolver_nothing)
    assert len(problems) == 3
    assert all("republish CNAME" in p for p in problems)


def test_no_tokens_short_circuits():
    problems = check_identity("example.com", {"Status": "SUCCESS", "Tokens": []},
                              resolver_all_good)
    assert any("not configured" in p for p in problems)


def test_wrong_target_is_caught():
    problems = check_identity("example.com", DKIM_OK,
                              lambda _n: "somewhere.else.example")
    assert all("expected" in p for p in problems)
ses-dkim-drift-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkIdentity, expectedCname } from './ses-dkim-drift-check.mjs';

const DKIM_OK = { Status: 'SUCCESS', Tokens: ['aaa', 'bbb', 'ccc'] };
const allGood = async (name) => `${name.split('._domainkey.')[0]}.dkim.amazonses.com`;
const nothing = async () => null;

test('expectedCname shape', () => {
  const { name, want } = expectedCname('aaa', 'example.com');
  assert.equal(name, 'aaa._domainkey.example.com');
  assert.equal(want, 'aaa.dkim.amazonses.com');
});

test('all records present is clean', async () => {
  assert.deepEqual(await checkIdentity('example.com', DKIM_OK, allGood), []);
});

test('missing records are reported with the fix', async () => {
  const problems = await checkIdentity('example.com', DKIM_OK, nothing);
  assert.equal(problems.length, 3);
  assert.ok(problems.every((p) => p.includes('republish CNAME')));
});

test('no tokens short-circuits', async () => {
  const problems = await checkIdentity('example.com', { Status: 'SUCCESS', Tokens: [] }, allGood);
  assert.ok(problems.some((p) => p.includes('not configured')));
});

FAQ

How can an identity be verified but not signing?

SES tracks verification and DKIM separately. Verification can be satisfied by a TXT record while DKIM depends on three CNAMEs. Remove the CNAMEs and the domain still reads verified, but messages go out without a valid signature.

What actually breaks when DKIM stops?

DMARC falls back to the SPF leg. If you have not configured a custom MAIL FROM, SPF authenticates an amazonses.com subdomain and does not align, so DMARC fails outright and mail starts landing in spam.

Do the DKIM tokens change if I republish them?

No. The tokens belong to the identity, so republishing the same three CNAMEs restores signing. They only change if the identity is deleted and recreated, in which case every record must be replaced.

Why does this usually happen after a DNS migration?

Easy DKIM records look like machine noise — three long random names pointing at dkim.amazonses.com. In a zone export or a manual rebuild at a new registrar they are the records most likely to be dropped as junk.

How often should I run this check?

Weekly is enough. It is a drift problem, so the value is in catching the next migration rather than in any single run.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.