All postsn8n · webhooks

Why your webhook signature check fails in n8n

The signature covers the exact bytes the sender posted, and a parsed JSON body is not those bytes. The Raw Body option fixes it, and I wrote a node so nobody has to hand-roll the rest.

If you search for "n8n webhook signature" you find the same thread many times. Someone sets up a Stripe or GitHub webhook, copies the signing secret, builds the HMAC in a Code node, and the result never matches. The secret is right. The algorithm is right. The check still says no.

I read enough of those threads to want to fix the cause instead of answering one more of them. This post is about the cause, and about the small node I published afterwards.

What the signature is computed over

A sender signs the exact bytes of the request body. Not the JSON it represents, the bytes. GitHub takes the body, runs HMAC-SHA256 with your secret and sends the hex digest in X-Hub-Signature-256 as sha256=<hex>. If one byte of the body changes, the digest is a different string.

On your side the check is short. This is plain Node, nothing n8n specific:

import { createHmac, timingSafeEqual } from "node:crypto";

function githubSignatureOk(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

The one argument that matters is rawBody. It has to be what came over the wire.

Where n8n gets in the way

The Webhook node parses a JSON body for you. That is what you want almost every time, because the next node can use {{ $json.body.action }} directly. But if your Code node then does JSON.stringify($json.body) and signs that, you are signing a new string. It has the same data and usually not the same bytes. Whitespace, key order, how a slash or a non-ASCII character is escaped: any of them can differ from what the sender wrote.

So the check fails, and it fails in the most confusing way, because if you print both bodies they look identical.

The fix is one option. On the Webhook node, add Raw Body. It tells the node to keep the original bytes, and those are what you sign.

The node

Knowing the cause is half of it. The other half is that every provider wraps the signature differently, and people paste a different variant of the same code each time. So I wrote an n8n community node, n8n-nodes-verify-webhook.

It has two outputs. A request with a good signature leaves through Valid. Everything else leaves through Invalid, so a workflow never acts on a forged call. You can also set On Invalid to stop with an error if you prefer that to a second output.

It knows these providers:

ProviderHeaderWhat is signed
GitHubX-Hub-Signature-256the body, sha256=<hex>
ShopifyX-Shopify-Hmac-Sha256the body, base64
StripeStripe-Signature<t>.<body>, and t is checked against a tolerance
SlackX-Slack-Signaturev0:<timestamp>:<body>, timestamp checked
ZoomX-Zm-Signaturev0:<timestamp>:<body>, timestamp checked
Genericyour choicethe body

The generic one covers anything else. It does HMAC with SHA-1, SHA-256 or SHA-512, hex or base64, and an optional prefix, which is enough for most services that sign their calls.

The parts that are easy to skip

Two details are why I did not want people copying snippets around.

The first is the comparison. A normal === on two strings can leak, through timing, how many leading characters were right. The node compares in constant time. It is a small thing, and it is exactly the kind of thing that gets dropped when you are trying to make a red check go green.

The second is replays. Stripe, Slack and Zoom put a timestamp into what they sign. That is there so someone who captured a valid request cannot send it again an hour later. The node rejects a timestamp that is more than 300 seconds from now. You can change the tolerance, and 0 turns the check off. If you hand-roll the HMAC and never look at t, you have a signature check that a recorded request passes forever.

When a request is rejected, the Invalid output carries the reason in verification.reason. "Timestamp outside tolerance" and "signature mismatch" send you in very different directions, and a bare false would hide that.

How to use it

In n8n, go to Settings, Community Nodes and install n8n-nodes-verify-webhook. Then:

  1. On the Webhook node, add the option Raw Body.
  2. Connect a Verify Webhook node, pick the provider and paste the signing secret.
  3. Use the Valid output for the real work.

The node reads the signature and, for Slack and Zoom, the timestamp from the headers of the incoming item. The header names are filled in per provider and you can change them.

What I would tell myself first

The check was never the hard part. Eight lines of HMAC are not hard. What is hard is that the input to the check lives upstream of your code, in a node that is trying to be helpful, and the failure message says nothing about it.

When a signature will not match and everything you can see looks right, stop staring at the secret. Ask what bytes you are really signing.

The package is MIT licensed. If you find a provider it should know about or a header it gets wrong, open an issue on the repo and I will look.