Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Signing and replaying DKIM email in the browser with Web Crypto

An AWS Security Blog post from November 2, 2026, "Beyond DKIM key rotation: understanding email replay attacks", makes a claim that sounds wrong at first: rotating your DKIM key does not stop an attacker from replaying m

Signing and replaying DKIM email in the browser with Web Crypto

An AWS Security Blog post from November 2, 2026, "Beyond DKIM key rotation: understanding email replay attacks", makes a claim that sounds wrong at first: rotating your DKIM key does not stop an attacker from replaying mail you already signed, and neither does DMARC p=reject.

The argument is simple once you see it. A DKIM signature only says "these bytes haven't changed since I signed them." If an attacker resends your genuine email unchanged, the signature still verifies, DMARC passes on DKIM alone, and p=reject never applies. Only deleting the old public key from DNS makes those captured copies fail.

I wanted something I could click through, so I built a small simulator with Claude Code. This post is about how it works under the hood: signing and verifying DKIM-style signatures with the Web Crypto API, modeling DMARC, and two bugs that testing caught.

The shape of the simulator

It is one HTML file with no dependencies. There is a sender (example.com on Amazon SES), an attacker who can capture and resend a message, a DNS panel where you rotate or delete keys and change the DMARC policy, and a receiving server that shows SPF, DKIM, and DMARC results with a reason for each.

Everything that looks like DNS is a JavaScript object mapping selectors to public keys:

S.dns = { s1: { p: '<base64 SPKI public key>' } };

Rotating a key adds a new selector and switches the sender to it. Deleting a key removes the entry. That mirrors the distinction the AWS post cares about: rotation and removal are separate operations.

Signing like DKIM, with Web Crypto

DKIM signs in two steps. It hashes the canonicalized body into the bh= tag, then signs the canonicalized headers plus the DKIM-Signature header itself, with b= left empty.

Key generation uses RSASSA-PKCS1-v1_5 with SHA-256, the same algorithm as DKIM's rsa-sha256:

const ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };

async function genKey() {
  const kp = await crypto.subtle.generateKey(
    { ...ALG, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
    true, ['sign', 'verify']);
  return { priv: kp.privateKey, p: b64(await crypto.subtle.exportKey('spki', kp.publicKey)) };
}

Exporting the public key as SPKI and base64-encoding it gives you the same kind of string you would put after p= in a real DKIM TXT record.

Canonicalization is a simplified version of DKIM's relaxed mode. For the body, it collapses runs of whitespace, strips trailing spaces, and drops trailing empty lines. For headers, it lowercases the name and collapses whitespace in the value:

function canonBody(body) {
  const lines = body.replace(/\r\n/g, '\n').split('\n')
    .map(l => l.replace(/[ \t]+/g, ' ').replace(/ +$/, ''));
  while (lines.length && lines[lines.length - 1] === '') lines.pop();
  return lines.join('\r\n') + '\r\n';
}
const canonH = (n, v) => n.toLowerCase() + ':' + v.replace(/\s+/g, ' ').trim();

Then the signature:

const SIGNED = ['from', 'to', 'subject', 'date'];
const headerBlock = (msg, dkimBase) =>
  SIGNED.map(h => canonH(h, msg.headers[h])).join('\r\n') + '\r\n' + canonH('dkim-signature', dkimBase);

async function signMsg(msg, sel) {
  const bh = await sha256b64(canonBody(msg.body));
  const base = `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=${sel}; h=${SIGNED.join(':')}; bh=${bh}; b=`;
  const sig = await crypto.subtle.sign(ALG, S.keys[sel].priv, enc.encode(headerBlock(msg, base)));
  msg.dkim = { d: 'example.com', s: sel, bh, base, b: b64(sig) };
}

This is not a full DKIM implementation. The signed header list is fixed, there is no l= tag, and canonicalization only covers the common cases. It is close enough that tampering behaves the way it does in real mail.

Verifying, with a reason for every failure

The receiver looks up the selector, checks the body hash, and then verifies the signature. Each step returns its own failure code, so the UI can tell you why something failed:

async function verifyDkim(msg) {
  if (!msg.dkim) return { r: 'none', code: 'none' };
  const { s, d, bh, base, b } = msg.dkim;
  const rec = S.dns[s];
  if (!rec) return { r: 'fail', code: 'nokey', s, d };
  if (await sha256b64(canonBody(msg.body)) !== bh) return { r: 'fail', code: 'body', s, d };
  const key = await crypto.subtle.importKey('spki', unb64(rec.p), ALG, false, ['verify']);
  const ok = await crypto.subtle.verify(ALG, key, unb64(b), enc.encode(headerBlock(msg, base)));
  return ok ? { r: 'pass', code: 'pass', s, d } : { r: 'fail', code: 'sig', s, d };
}

The three failure codes line up with the three ways a replay can go wrong for the attacker.

  • nokey: the old key was deleted from DNS
  • body: the attacker changed the body, so bh= no longer matches
  • sig: the attacker changed a signed header such as the subject

The codes are not translated strings because the page switches between Japanese and English, and the log re-renders in the new language.

Modeling DMARC

DMARC passes if SPF or DKIM passes and aligns with the From domain. The policy only decides what happens when DMARC fails:

const dmarc = ((spf === 'pass' && spfAligned) || aligned) ? 'pass' : 'fail';
const disp = dmarc === 'pass' ? 'deliver'
  : { none: 'deliver', quarantine: 'quarantine', reject: 'reject' }[S.dmarc];

That second line is the whole reason p=reject can't stop a replay. If DKIM passes, the policy lookup never happens.

Bug 1: SPF alignment

My first version set spfAligned to true for the sender's own IP. The result: after deleting the old key, an in-flight legitimate email signed with that key still got delivered, because SPF carried it.

That doesn't match how Amazon SES works by default. SES uses a subdomain of amazonses.com as the envelope sender (MAIL FROM), so SPF passes for amazonses.com, not for the From domain. Unless you configure a custom MAIL FROM domain, DKIM is the only thing aligning your SES mail for DMARC.

With that fixed, deleting the old key too early rejects your own in-flight mail at p=reject. The AWS post recommends waiting until in-flight mail has arrived before removing the old key, and this is the failure that advice prevents.

Bug 2: the edit that wasn't sent

To test tampering, I appended one character to the captured body and hit "resend". DKIM passed. The crypto was fine. The click handler called render() before running the action, and render() rebuilt the textarea from the original message, so the unmodified body was what got sent.

The fix was to keep the draft in state on every input event and render from that:

document.addEventListener('input', e => {
  const k = e.target.dataset && e.target.dataset.draft;
  if (k && S.draft) S.draft[k] = e.target.value;
});

After that, a body edit failed with the body hash reason and a subject edit failed with the signature reason, as they should.

One more thing: secure contexts

crypto.subtle only exists in secure contexts. The first time I opened the page from a data: URL, it wasn't available. Serving it from localhost fixed that:

python3 -m http.server 3370 --directory dkim-replay-simulator

What the runs showed

With the bugs fixed, every claim I checked from the AWS post reproduced. A replay at p=reject was delivered. A replay after rotation, with the old key still in DNS, was delivered. A replay after deleting the old key was rejected with "no key found". A spoof with a random signature was rejected at p=reject, which is the case p=reject is designed for.

The simulated rua report ended up with a row of the same shape as the example in the AWS post: an unknown IP, thousands of messages, SPF softfail, DKIM pass. Building the receiver logic made the takeaway concrete. DMARC policies act on failures, and a replay of mail you signed isn't one, as long as the old key is still in DNS.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.