DEV Community

Houzelle
Houzelle

Posted on

Multi-tenant DKIM in Haraka: two signatures on one message

In my previous post
I described a catch-all email forwarder built on Haraka, and I waved at the
bring-your-own-domain case with one lazy sentence: "a small additive
queue_outbound hook that reuses the DKIM signing stream."

That sentence hid the hardest part of the whole system. This post is the part I
skipped: how to DKIM-sign outbound mail for hundreds of customer domains,
with per-tenant keys living in a database, from a single Haraka instance. It's
what powers custom domains on Tomatoes.run.

Why one signature isn't enough

Forwarding mail means rewriting the envelope sender (SRS) so SPF passes on
your domain. Fine. So you DKIM-sign with d=example.com and call it a day.

Then a customer points mail.theircompany.com at you, and DMARC breaks.

The reason is alignment. DMARC doesn't check the envelope — it checks the
domain in the visible From: header, and demands that either SPF or DKIM
pass for that domain. After SRS, your envelope says example.com while the
header still says theircompany.com. Nothing aligns with the header domain, so
DMARC fails even though both SPF and DKIM technically "pass".

You need a signature whose d= is the customer's domain. And you still need the
first one, for the SRS return-path. So: two signatures on one message.

Step 1 — mark the message on the way in

DKIM signing happens late, at queue time. The decision about which domain to
sign for is made much earlier, when you know the recipient. So you stash it on
the transaction:

// Tell haraka-plugin-dkim this is a relayed message: it signs with the
// envelope domain (SRS-rewritten to ours), not the original From — we
// have no key for that one.
txn.notes.forward = true;

// Custom domain? Ask for a SECOND signature with d=<their domain>.
// Our own catch-all subdomains (you.example.com) are excluded: they're
// already covered by the envelope signature.
const rcptHost = rcpt.slice(rcpt.lastIndexOf('@') + 1);
if (rcptHost !== cfg.domain && !rcptHost.endsWith(`.${cfg.domain}`)) {
  txn.notes.sign_domain = rcptHost;
}
Enter fullscreen mode Exit fullscreen mode

The exclusion matters. Signing d=you.example.com would need a key per
subdomain, and there's no point: the envelope signature on the parent domain
already aligns for those.

Step 2 — add the second signature at queue_outbound

Here's the trick, and it depends on plugin order. Your custom hook must run
after haraka-plugin-dkim (order is set in config/plugins), so the first
signature already exists and you're purely additive.

const { DKIMSignStream } = require('haraka-plugin-dkim/lib/dkim');

exports.hook_queue_outbound = function (next, connection) {
  const txn = connection.transaction;
  const domain = txn && txn.notes.sign_domain;
  if (!domain || !DKIMSignStream) return next();

  this.fetch_dkim(domain, (err, key) => {
    if (err || !key) return next();  // no key provisioned -> ship it anyway

    let done = false;
    const finish = (e) => {
      if (done) return;            // never call next() twice
      done = true;
      try { txn.message_stream.unpipe(); } catch (_) {}
      return next();
    };

    const stream = new DKIMSignStream(
      { domain, selector: key.selector, private_key: key.privateKey,
        headers: DKIM_SIGN_HEADERS, body_canon: 'relaxed' },
      txn.header,
      (e, dkimHeader) => {
        if (e) return finish(e);
        if (dkimHeader) txn.add_header('DKIM-Signature', dkimHeader);
        return finish();
      }
    );
    txn.message_stream.pipe(stream, {});
  });
};
Enter fullscreen mode Exit fullscreen mode

Two details worth stealing:

  • Reuse DKIMSignStream instead of reimplementing signing. It's an internal path of haraka-plugin-dkim, so I require it inside a try/catch and leave the binding null on failure. If upstream reshuffles its files, custom signing quietly stops — mail still flows, signed with the envelope domain. A missing feature beats a crashed MX.
  • The done flag is not paranoia. A stream that both errors and fires its callback would call next() twice, and Haraka will happily queue the message twice. Guard it.

Step 3 — per-tenant keys

Each verified domain gets an RSA keypair at signup: public half goes in the
customer's DNS as <selector>._domainkey, private half into Postgres.

Haraka fetches it through a read-only internal endpoint, authenticated with the
same shared secret as the rest of the internal API:

// POST /internal/email/dkim  { domain } -> { selector, privateKey } | { key: null }
const d = await prisma.domain.findFirst({
  where: { domain: domain.toLowerCase(), verifiedAt: { not: null } },
  select: { dkimSelector: true, dkimPrivateKey: true },
});
if (!d?.dkimSelector || !d?.dkimPrivateKey) return json({ key: null });
Enter fullscreen mode Exit fullscreen mode

Note verifiedAt: { not: null } — an unverified domain never gets a key served,
which is the same gate that stops the MX from becoming an open relay.

Keys are cached in-process with a TTL, because otherwise every single message
costs an HTTP round-trip plus a DB read.

The part I find most interesting: opposite failure modes

Both of these talk to the same internal API. They fail in opposite
directions
, on purpose.

Recipient validation fails closed. At hook_rcpt, if the API is
unreachable, you answer DENYSOFT — a 4xx tempfail. SMTP is store-and-forward,
so the sender retries for days; a few minutes of downtime loses nothing. Accept
optimistically here and you're an open relay.

catch (err) { return next(DENYSOFT); }   // never accept what you can't validate
Enter fullscreen mode Exit fullscreen mode

Signing fails open. At hook_queue_outbound, every error path just calls
next(). API down, key missing, stream throws, module moved — the message goes
out anyway, already signed with the envelope domain.

if (err) { plugin.logwarn(...); return next(); }   // ship it
Enter fullscreen mode Exit fullscreen mode

The asymmetry is the whole design. Failing closed on validation protects
everyone else from you relaying spam. Failing closed on signing would protect
nobody — it would just silently stop your customer's mail over a degraded
optional feature. Match the failure direction to who gets hurt.

Gotchas and things I'm still unsure about

Being honest about the rough edges, because this is the stuff that costs you a
weekend:

  • Piping message_stream a second time at queue time is the part I trust least. The first signature already consumed the stream; this hook attaches another consumer to it. It works in my testing and the unpipe() in finish() keeps it tidy, but I'd call it "validated on my traffic", not "battle-tested at scale". If you know a cleaner way to get two signatures out of Haraka, the comments are open — that's half my reason for writing this.
  • Negative results aren't cached. A verified domain with no key provisioned hits the API on every message. Easy fix, still on my list.
  • The signed-headers list is duplicated between the plugin constant and config/dkim.ini [sign]. Two places, one meaning, guaranteed to drift.
  • Private keys sit in Postgres, on the same trust boundary as forwarding addresses. Defensible for a small operation, and honestly the thing I'd move to a KMS first if this grew.
  • DMARC is about the header From, not the envelope. If you remember one sentence from this post, that's the one — almost every forwarding deliverability bug I've hit traces back to forgetting it.

This runs in production as Tomatoes.run, a
France/EU-hosted take on per-service email aliases — every address on your
subdomain works instantly, and Premium users can bring their own domain (so
leaving is just repointing an MX record). If you've done multi-tenant DKIM
differently, I genuinely want to hear it. 🍅

Top comments (0)