
n8n webhook authentication is off by default, which means any Webhook node you haven't touched will run its workflow for anyone who sends a request to its URL. The fix is to set the Webhook node's Authentication to Header Auth (or Basic or JWT) for senders you control, and to verify HMAC signatures over the raw body for services that sign their requests, like Stripe, GitHub or Shopify. An IP allowlist can sit on top, but only if your proxy settings are right, and a "secret" URL path is not authentication at all.
When we audit client n8n instances, webhooks with Authentication set to None are the norm, not the exception. Usually they still work fine, so nobody looks. The problem shows up the day someone finds the URL and starts posting fake leads into your CRM or triggering the workflow that sends SMS.
We wanted real behavior rather than what the docs imply, so on October 16, 2026 we ran n8n 2.44.3 in Docker and hit test webhooks with every kind of good and bad request. The results are below.
Why does an n8n webhook need authentication?
A production webhook URL looks like https://your-n8n.com/webhook/new-lead. Whoever has that string can start your workflow, with whatever body they want. Think about where the URL actually lives: in a form plugin's settings, a Zapier step, a vendor's dashboard, a browser's network tab, a Loom video someone recorded, a shared Slack screenshot.
An unauthenticated webhook trusts the URL to stay secret, and URLs don't stay secret.
Readable paths make it worse. In our test, n8n answered differently depending on whether a path existed:
- A request to a path that doesn't exist returned 404 with
The requested webhook "POST doesnotexist" is not registered. - A GET request to a real POST-only path returned 404 with
This webhook is not registered for GET requests. Did you mean to make a POST request?
That second message confirms the path is real. So a list of guesses like new-lead, stripe, contact-form or retell can be checked one by one with no auth involved. Random paths help, but they're a speed bump, not a lock.
What's at stake depends on what the workflow does. A webhook that writes to a sheet costs you a messy sheet. One that sends SMS, calls an AI model, creates invoices or updates ad platform conversions costs real money, and can poison the data your ad platforms optimize on. If you're also running AI agents behind webhooks, a forged request is a direct path to the prompt injection risks we've covered before.
What authentication options does the n8n Webhook node have?
The Webhook node has four settings under Authentication: Basic Auth, Header Auth, JWT Auth and None, per the official Webhook node docs. Separately, under Options, there's an IP(s) Allowlist, a Raw Body toggle and Ignore Bots.
Here's what each one did in our test:
- Header Auth. No header or a wrong value returned HTTP 403 with
Authorization data is wrong!. The right value returned 200. The header name matched case-insensitively, sox-api-keyandX-Api-Keyboth worked. - Basic Auth. No credentials returned 401 with
Authorization is required!, and wrong credentials returned 401 withAuthentication data is wrong!. - None. Every request ran the workflow.
- IP allowlist. Requests from outside the list returned 403 with
IP is not allowed to access the webhook!. The proxy problem with this one gets its own section below.
The detail that matters most: rejected Header Auth and Basic Auth requests created zero executions. The node stops them before the workflow runs. Our signature-checking workflow, by contrast, logged one execution per request, including the three it rejected. If someone floods a URL, node-level auth keeps that noise out of your executions list and away from any plan limits that count executions.
Which method should you use for each sender?
Pick based on what the sending service can do, not on what feels most secure:
- The service signs its requests (Stripe, GitHub, Shopify, Calendly, many voice AI and payment platforms): verify the HMAC signature. This is the only option that proves the body wasn't changed and that it came from the holder of the signing secret.
- You can add a custom header (your own app, Zapier, Make, GoHighLevel custom webhooks, most CRMs): Header Auth with a long random value. Generate it with
openssl rand -hex 32, not a word you can remember. - The service only lets you paste a URL: Basic Auth with credentials in the URL (
https://user:pass@host/webhook/...) if the service supports it. A token in the query string works too, but query strings end up in proxy and analytics logs, so treat it as the weakest option. - One of your own workflows calling another: don't use a webhook. The Execute Workflow node never leaves n8n, so there's no URL to protect.
- A public browser form: any secret you put in front-end code is public. Use the n8n Form Trigger or a backend that holds the secret, and add bot protection. Header Auth won't help here.
Default to Header Auth for anything you control, and to signature verification for anything that signs. Most instances we clean up end up with roughly that split.
How do you verify an HMAC signature in n8n?
Most signed webhooks follow the same pattern. The sender takes the raw request body, often prefixed with a timestamp, computes an HMAC-SHA256 with a shared secret and puts the result in a header. Stripe, for example, signs timestamp.payload and recommends rejecting anything outside a five-minute window, per its webhook signature docs. Your job is to recompute the same hash and compare.
In n8n that takes three nodes:
- Webhook node with Respond set to "Using 'Respond to Webhook' Node" and Raw Body turned on under Options.
- Code node that recomputes and compares the signature.
- IF node that sends invalid requests to a Respond to Webhook node returning 401, and valid ones on to the real work.
Here's the Code node we used, with the header names adjusted for a generic sender:
const crypto = require('crypto');
const SECRET = 'whsec_...'; // see the note on secrets below
const item = $input.first();
const sig = item.json.headers['x-signature'] || '';
const ts = item.json.headers['x-timestamp'] || '';
const raw = await this.helpers.getBinaryDataBuffer(0, 'data');
const expected = crypto.createHmac('sha256', SECRET)
.update(`${ts}.${raw.toString('utf8')}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const match = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
return [{ json: { valid: match && fresh, body: item.json.body } }];
Two setup notes. On self-hosted n8n, require('crypto') needs NODE_FUNCTION_ALLOW_BUILTIN=crypto in the environment. And keep the secret out of places people casually screenshot. You can read it from an environment variable with $env, unless the instance sets N8N_BLOCK_ENV_ACCESS_IN_NODE=true (many hardened setups do). Otherwise, keep it in the node and limit who can edit that workflow.
Our test results for this workflow:
- Valid signature: 200.
- Body changed after signing (amount edited from 10.50 to 99.50): 401.
- Valid signature, timestamp 15 minutes old: the signature still matched, and only the freshness check rejected it with 401. Without a timestamp check, a captured request can be replayed forever.
- No signature header: 401.
Why does n8n HMAC verification fail with a correct secret?
This is the bug we see most in hand-built signature checks, and our test reproduced it cleanly.
The common tutorial approach hashes JSON.stringify($json.body). That's the parsed body turned back into a string, not the bytes the sender signed. We sent the same logical payload two ways:
- Compact JSON,
{"event":"lead.created","amount":11}: both the raw-body check and the stringify check matched. - The same JSON with spaces,
{"event": "lead.created", "amount": 10.50}: the raw-body check matched, the stringify check failed.
Re-serializing drops the spaces and turns 10.50 into 10.5, so the hash changes. That's why this bug survives testing. You test with a compact payload from curl or your own script, it passes, and then the real provider sends pretty-printed JSON or a decimal with a trailing zero and every request fails in production.
Always hash the raw body. Turn on Raw Body and read it with getBinaryDataBuffer, and never rebuild the payload from parsed JSON.
Compare with crypto.timingSafeEqual, not === or .includes(). A plain string comparison can leak timing information, and .includes() will happily accept a header that contains the right hash next to garbage.
Is the n8n webhook IP allowlist enough?
It's a good second layer when the sender publishes fixed IPs. It's dangerous as your only layer, because it depends entirely on where n8n thinks the request came from.
We put a webhook behind an allowlist of one IP and tested it two ways:
- Default settings (
N8N_PROXY_HOPS= 0): a request carryingX-Forwarded-Forwith the allowed IP was rejected with 403. n8n looked at the connecting address, which behind a reverse proxy is the proxy, so legitimate senders get blocked. People "fix" that by allowlisting the proxy's IP, which allows everyone. - With
N8N_PROXY_HOPS=1: n8n trusts theX-Forwarded-Forheader. With the n8n port reachable directly, our request with a spoofedX-Forwarded-Forheader got straight through with 200.
The deployment environment variable docs describe N8N_PROXY_HOPS simply as the number of reverse proxies in front of n8n. Set it to match your setup, and make sure port 5678 isn't open to the internet, so every request has to pass through a proxy that writes the forwarding header itself. An allowlist is only as honest as the header n8n reads.
How do you audit an instance for open webhooks?
You don't need to click through every workflow. The n8n public API returns node parameters, so one command lists every active webhook with no authentication:
curl -s -H "X-N8N-API-KEY: $N8N_API_KEY" \
"https://your-n8n.com/api/v1/workflows?limit=250" |
jq -r '.data[] | select(.active) | .name as $w | .nodes[]
| select(.type == "n8n-nodes-base.webhook")
| select((.parameters.authentication // "none") == "none")
| "\($w)\t/webhook/\(.parameters.path)"'
If you have more than 250 workflows, follow the nextCursor value in the response. Then work the list in this order:
- Webhooks that spend money or send messages (SMS, email, AI calls, invoices, ad conversions) get fixed first.
- Webhooks that write to systems of record (CRM, billing, attribution) next. Forged rows there cause long, quiet damage. Duplicate events are a related problem, and our guide to n8n webhook duplicate executions covers idempotency for the legitimate traffic.
- Read-only or test hooks last, or delete them. Old test workflows that are still active are a surprisingly common find.
After each change, send one request with no secret and one with a wrong secret, and confirm both are rejected. Then send a real one from the actual service. A secret that's updated in n8n but not in the sender breaks the integration silently, so make sure your error handling and monitoring would catch the drop in volume.
Authentication also doesn't replace patching. Webhook and trigger nodes have had their own security fixes, so keep the instance current with a routine like the one in our self-hosted n8n patching guide.
The short version
- Senders you control: Header Auth with a 64-character random secret. Rejections never become executions.
- Senders that sign requests: Raw Body on, HMAC over the exact bytes plus timestamp,
timingSafeEqual, five-minute window. - Fixed-IP senders: add the allowlist on top, with
N8N_PROXY_HOPSset correctly and n8n's port closed to the internet. - Workflow-to-workflow: Execute Workflow, not a webhook.
- Everything: audit with the API, fix the money-spending hooks first, and test the rejection path, not just the happy path.
Get a free automation audit
If you're not sure which of your n8n webhooks are open, we'll check for you. Our team runs the audit above on your instance, flags the hooks that spend money or touch client data, and shows you the exact authentication setup for each sender. Get a Free Automation Audit, or see how we build and run production workflows on our n8n workflow automation service page.
