Signing Mailchimp webhooks in n8n: key rotation and the workflows that never get a secret

n8n adds X-Mailchimp-Signature verification to its Mailchimp trigger. Two non-blocking points: keep every v1 signature for rotation, and existing workflows silently stay unverified.

Category
Code review
Published
Reading time
3 min read

The problem

From the pull request: before this change, the Mailchimp Trigger node "accepted every POST to its webhook URL". Anyone who learned the URL could start workflow runs with made-up Mailchimp events. According to the pull request, Mailchimp now sends an X-Mailchimp-Signature header containing a timestamp and an HMAC-SHA256 of <timestamp>.<raw body>, keyed with a signing secret that Mailchimp returns once, in the response that creates the webhook.

The proposed fix

The node stores the secret when it creates the webhook and verifies every delivery with n8n's shared helper, which compares in constant time and rejects timestamps older than five minutes:

MailchimpTriggerHelpers.ts
const hmac = createHmac('sha256', secret);
hmac.update(`${timestamp}.`);
hmac.update(payload);
return hmac.digest('hex');
MailchimpTrigger.node.ts
if (!verifySignature.call(this)) {
  const res = this.getResponseObject();
  res.status(401).send('Unauthorized').end();
  return { noWebhookResponse: true };
}

It signs the raw body rather than the parsed form body, which matters because Mailchimp posts form-encoded data and re-serializing it would not reproduce the original bytes. The tests cover a wrong secret, a changed body, a missing header, an expired timestamp and a missing raw body.

What I found in review

Keep every v1 signature

The header parser keeps only the last v1= value. In the t=...,v1=... scheme that Stripe uses, and that this header format mirrors, the header can carry one signature per active secret while a secret is being rotated, and the receiver should accept if any of them matches. With "last one wins", a valid first signature fails with 401. Collecting all v1 values and comparing each in constant time costs little; a test with t=...,v1=<bad>,v1=<good> would pin it.

The upgrade path is silent

The secret is captured in create. For a workflow that already has a Mailchimp webhook, checkExists returns true, so create never runs, no secret is stored, and verification is skipped because there is no secret to verify with. Existing workflows therefore stay unverified until someone deactivates and reactivates them, and nothing tells their owners. I suggested one of:

  1. log a one-time warning when a request arrives and no secret is stored, or
  2. make checkExists return false when a webhook id exists but the secret is missing, after deleting the old webhook so no duplicate is created; existing workflows then pick up signing on their next activation.

The documentation checkbox in the pull request was also unticked, and the "deactivate and activate to enable verification" step belongs there.

Say when it degrades

If the create response carries no signing_secret, the node falls back to accepting unsigned requests. A this.logger.warn(...) there would make that visible.

What happened next

Takeaways

  • Signature schemes with a list of signatures exist for key rotation. A parser that keeps one value breaks rotation.
  • When a provider shows a secret only once, every integration created before your code existed has no secret. Plan the upgrade path, or at least make the unprotected state visible.
  • "Fail open when there is no secret" is a compatibility decision. It should be a logged one.

References

Code and history behind this post

More

Theme