What happens when someone who isn't Stripe sends a POST to your Stripe webhook URL? If the workflow behind it starts with a plain Webhook node and no check, the answer is that n8n runs it, marks the invoice paid, provisions the account, sends the email. The URL has a random path in it, which is some protection, but a path is not a secret once it's in a Stripe dashboard, a browser history, a proxy log and a screenshot on a support ticket. Providers solve this by signing every delivery with a secret only you and they know. Instance-level hardening (proxy binding, 2FA, SSRF protection, runner isolation) lives in the n8n security best practices checklist; this article covers only the ways n8n can check those signatures, from the options built into the Webhook node through the trigger nodes that verify for you, down to a hand-written HMAC check for a provider that has no dedicated node. The Stripe example that used to sit on this page ignored the timestamp in the signature; the replacement below is the one I run.
Authentication options on the Webhook node
The Webhook node ships with four settings under Authentication: None, Basic Auth, Header Auth and JWT Auth. Each of the last three stores its expected value in a credential, and a request that doesn't carry it gets rejected before the workflow starts, without spending an execution. There's also an IP(s) Allowlist option in the node's settings that takes a comma-separated list of addresses and an Ignore Bots toggle for link previewers.
Header Auth is what I use for anything I control on both ends: a cron job on another server, an internal admin panel, a second n8n instance. Pick a header name, generate a long random value, put it in the credential and in the caller. For a third-party SaaS that lets you configure a custom header on outgoing webhooks, it's just as good. Where it stops working is the big providers. Stripe and GitHub don't let you attach an arbitrary header, they sign the body instead, and for those you need one of the next two sections.
One thing to know about the allowlist behind a reverse proxy: n8n only sees the real client address if N8N_PROXY_HOPS is set to the number of proxies in front of it and the proxy forwards X-Forwarded-For. The piece on webhook URLs behind a reverse proxy lists the variables involved, and the same setting decides what the production URL shown in the node looks like. Without it every request appears to come from 127.0.0.1 and the allowlist either blocks everything or, if you allowlisted localhost while debugging, nothing.
Stripe Trigger and GitHub Trigger nodes verify for you
For Stripe, the Stripe Trigger node is the right answer for almost everyone, and I say that as someone who wrote the manual version first. The Stripe credential has a Signature Secret field. Fill it with the whsec_ value from the endpoint Stripe created when you published the workflow, and the node checks the Stripe-Signature header on every request, rejecting anything unsigned, forged or more than five minutes old with a 401. Test mode and live mode endpoints have different signing secrets, so a workflow that verifies in test and fails in live has usually been given the wrong one.
The GitHub Trigger node goes a step further and generates the secret itself: when it registers the hook on the repository it passes a random 32-byte secret in the hook config, then verifies X-Hub-Signature-256 on each delivery. You never see or store that secret. Reading the node's source was how I found this out, the docs page for the trigger doesn't mention it, so if you're on an older 1.x release, check the behaviour before relying on it.
The manual method below is for providers without a trigger node or for a GitHub hook you configured by hand at the organisation level.
Manual HMAC verification with a Webhook node and a Code node
Signature schemes hash the exact bytes the provider sent. n8n's default behaviour is to parse the body into $json.body, and re-serialising that object will not reproduce the original bytes (key order, whitespace and unicode escapes all drift), so the first step is to stop n8n from parsing it.
Turn on Raw Body and read the body as binary
In the Webhook node, open Options and enable Raw Body. From then on $json.body arrives as an empty object and the untouched request body sits in the binary property named data, with the request's content type as its MIME type. Headers stay where they were, in $json.headers, with lower-case names. In a Code node set to Run Once for All Items, this reads the body back as a string:
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');
Don't reach for $binary.data.data directly. In filesystem or database binary mode that field holds a reference, not the content, and the helper above is what the docs point to for the Code node. There is no $json.body_raw and never was, whatever an older tutorial says.
Where the signing secret lives
A Code node can't read n8n credentials, and $env is blocked in Code nodes and expressions by default since 2.0. That leaves three places for the secret. First, set N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and read it from $env.STRIPE_WEBHOOK_SECRET; every editor user then gets to read every environment variable, including the database password and the encryption key, which is fine on a one-person instance and a bad idea anywhere else. Second, keep the secret in a credential by using the Crypto node instead of require('crypto') for the HMAC step; the Crypto credential has an Hmac Secret field, the node reads it, the Code node only does the comparison and never sees the secret. Third, custom variables via $vars, which need a Business or Enterprise license. The Stripe example below uses the first option because it keeps everything in one node; the GitHub example uses the second so you can see both.
NODE_FUNCTION_ALLOW_BUILTIN for require('crypto')
Both examples call require('crypto'), and a stock install refuses it. Set NODE_FUNCTION_ALLOW_BUILTIN=crypto on the process that runs the task runner: that's the n8n container in the default internal mode and the n8nio/runners sidecar in external mode. The module allowlist docs say the same thing in one line. A comma-separated list widens it and * allows every builtin, which I wouldn't do on a shared instance.
Stripe: HMAC of timestamp.body with a tolerance window
Stripe's header looks like t=1492774577,v1=5257a869...,v0=6ffbb59b..., and the manual verification steps on stripe.com spell out the rest. The signed string is the timestamp, a literal dot, then the raw body, and the signature is HMAC-SHA256 of that string keyed with your endpoint secret, hex-encoded. Ignore v0. There can be more than one v1 for up to 24 hours after you roll the secret in the Stripe dashboard, so the check has to accept any of them. Stripe's own libraries default to a 300 second tolerance on the timestamp, and the timestamp is inside the signed string, so an attacker can't move it without breaking the signature.
const crypto = require('crypto');
const secret = $env.STRIPE_WEBHOOK_SECRET;
const toleranceSeconds = 300;
const headers = $input.first().json.headers;
const header = headers['stripe-signature'] || '';
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value] = part.trim().split('=');
if (key === 't') timestamp = value;
if (key === 'v1') signatures.push(value);
}
if (!timestamp || signatures.length === 0) {
return [{ json: { verified: false, reason: 'missing signature header' } }];
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${raw}`, 'utf8')
.digest();
const match = signatures.some((sig) => {
const given = Buffer.from(sig, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!match) {
return [{ json: { verified: false, reason: 'signature mismatch' } }];
}
if (age > toleranceSeconds) {
return [{ json: { verified: false, reason: 'timestamp outside tolerance' } }];
}
return [{ json: { verified: true, event: JSON.parse(raw) } }];
timingSafeEqual throws if the two buffers differ in length, which is why the length check sits in front of it. The parsed event comes out in event for the rest of the workflow, since $json.body is empty in raw mode. The env var itself goes in the compose file next to the other n8n settings, as STRIPE_WEBHOOK_SECRET=whsec_..., and its worth restarting the container after adding it because n8n only reads the environment at startup.
Something I haven't tested: with external task runners, I don't know if $env resolves against the main container's environment or the sidecar's. I've only run this code on the default internal runner, where the answer is obviously the n8n container. If you're on external mode, try it with a throwaway variable first.
GitHub: X-Hub-Signature-256 with the Crypto node
GitHub signs only the raw body, no timestamp, and sends X-Hub-Signature-256: sha256=<hex>. The GitHub webhook validation docs ask for a constant-time comparison and for the body to be validated exactly as received, which the Raw Body option already gives you. The legacy X-Hub-Signature header is SHA-1 and you can ignore it.
Because there's no timestamp to concatenate, the Crypto node can hash the binary body directly and the secret can stay in a credential. Add a Crypto node after the Webhook node with these settings: Action Hmac, Type SHA256, Binary File on, Binary Property Name data, Property Name expected, Encoding HEX and a Crypto credential whose Hmac Secret is the secret you typed into the GitHub webhook form. The node adds expected to the item and passes the headers through. Then a Code node compares:
const crypto = require('crypto');
const item = $input.first();
const header = item.json.headers['x-hub-signature-256'] || '';
const expected = `sha256=${item.json.expected}`;
const a = Buffer.from(header, 'utf8');
const b = Buffer.from(expected, 'utf8');
const verified = a.length === b.length && crypto.timingSafeEqual(a, b);
return [{ json: { verified, delivery: item.json.headers['x-github-delivery'] } }];
Respond with 401 from the Respond to Webhook node
Set the Webhook node's Respond option to Using 'Respond to Webhook' Node. After the verification Code node, an IF node routes on {{ $json.verified }}. The false branch goes to a Respond to Webhook node with Response Code 401 and no body, the true branch goes to another Respond to Webhook with 200 and then to the real work. Answer first and process after: Stripe expects a 2xx quickly and treats a slow endpoint as failed, then retries with exponential backoff for up to three days in live mode, so the fulfilment logic goes after the response node.
A 401 also stops a forged delivery from showing up as a failed execution. If you'd rather see the rejections, log the reason to a data table or a Postgres row on the false branch before responding. I keep a count and nothing else, since the bodies of forged requests aren't something I want stored.
Replay protection and duplicate deliveries
The Stripe tolerance check above is the replay defence for Stripe. GitHub has no timestamp in its signature, so a captured delivery stays valid as long as the secret does; the mitigation there is the X-GitHub-Delivery header, a unique ID per delivery that you record and refuse to process twice. The same idea applies to Stripe for a different reason: Stripe says it can deliver the same event more than once and doesn't guarantee ordering, so keying your fulfilment on the event ID and treating a repeat as a no-op is how you avoid double-provisioning. A data table keyed on the ID is enough, and so is the upsert that the n8n ETL pipeline guide uses with the Postgres node. The same checkpoint idea works for any provider that gives you a delivery ID.
Rate limiting webhook paths at the proxy
Signature checks do nothing about volume. The surrounding server block with the websocket headers for the editor is in the n8n Nginx reverse proxy tutorial; the location below is the only part that changes. A flood of unsigned requests still costs a task runner invocation each, since the check happens inside the workflow, so put a limit in Nginx on the webhook location and let the flood die at the proxy:
limit_req_zone $binary_remote_addr zone=hooks:10m rate=20r/s;
location /webhook/ {
limit_req zone=hooks burst=40 nodelay;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Twenty requests a second per source IP with a burst of forty has never bitten a real provider on my instance. Stripe's retries are spaced out and GitHub sends one delivery per event, so the only traffic that hits the limit is the traffic you want limited. Pick numbers that fit your own volume, the point is that a ceiling exists.
Test and production webhook URLs
Every Webhook node has two URLs. The test URL contains /webhook-test/ and only listens while you're in the editor with the node waiting for a test event. The production URL contains /webhook/ and works once the workflow is published. Register the production URL with the provider; a test URL in a Stripe endpoint fails silently the moment you close the editor tab and Stripe will retry against it for days.
Both URLs are built from N8N_WEBHOOK_URL, so if the node shows http://localhost:5678/... the variable is missing and nothing external will reach either of them.

