DEV Community

HaoPeng Zhang
HaoPeng Zhang

Posted on

Your n8n Webhook URL Is a Password — And You've Probably Pasted It Somewhere Public

Your n8n Webhook URL Is a Password — And You've Probably Pasted It Somewhere Public

This is part of my build-in-public series on running LLM workflows in n8n in production. Everything here is from real templates I sell and support — the mistakes are mine, the fixes are tested.

The execution that shouldn't exist

A few months ago I was reviewing a client's execution list and found a run I couldn't explain. The workflow was a Webhook-triggered order processor — it only fires when their store sends an order event. But there it was: an execution at 3 AM, with a payload that didn't match their store's format at all. Garbage in, half-processed, then it failed on a missing field and paged me.

Someone — or something — had hit the webhook URL directly.

How? I never published the URL. But then I remembered: three weeks earlier I'd posted a screenshot of that workflow in a forum thread to illustrate a different problem. The Webhook node was visible. The full production URL was right there in the screenshot, readable at full resolution.

An n8n webhook URL is a bearer token. Whoever holds it can trigger your workflow. There is no second factor, no IP allowlist, no rate limit by default. My "private" workflow had been public since the day I shared that screenshot.

The three places webhook URLs leak

Once I started looking, I found my URLs in more places than I wanted to admit:

1. Screenshots and screen recordings. The #1 leak. You share a workflow to ask for help, the Webhook node is in frame, the URL is legible. Search any automation forum for webhook URL patterns and you'll find live ones.

2. Shared workflow JSON. Export a workflow and the Webhook node keeps its path, and anyone importing it into their own n8n gets... well, their own URL, actually — the domain changes. But the path stays, and if you've ever pasted the full URL into a Set node, a Code comment, or an HTTP node elsewhere, that travels with the JSON.

3. Logs and error messages. Webhook URLs end up in execution data, error payloads forwarded to alerting, and pasted into support tickets. Every copy is another bearer token in the wild.

n8n does give you separate test and production URLs, which helps — but most of us build against the production URL out of laziness and never switch.

The fix: treat the URL as a secret, then verify the caller anyway

Two layers. The first is hygiene; the second is the one that actually saves you.

Layer 1 — hygiene (do this today).

  • Turn on authentication on the Webhook node. n8n's Webhook node supports Header Auth — a shared secret sent in a header, checked before the workflow runs. It takes one minute to enable and it would have stopped my 3 AM execution cold.
  • Use the test URL while building, switch to production only when shipping.
  • If a URL leaks, change the webhook path. A new path is a new URL — the old one dies instantly. This is your rotation mechanism; use it like one.
  • Never post screenshots with the Webhook node expanded. Blur it or crop it. I learned this the expensive way so you don't have to.

Layer 2 — verify the signature (for webhooks you don't control).

Header Auth works when you choose the secret. But when the caller is Stripe, Shopify, or a client's store, they sign the payload instead — typically HMAC-SHA256 of the raw body with a signing secret, sent in a header. n8n won't verify that for you, so the first node after the Webhook must do it, before any side effect runs:

// Code node, first step after the Webhook trigger — fail closed
const crypto = require('crypto');
const secret = $env.WEBHOOK_SIGNING_SECRET; // never hardcode this
const rawBody = $json._rawBody ?? JSON.stringify($json.body);
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = $json.headers['x-signature-256'] ?? '';
const ok = received.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) throw new Error('webhook signature mismatch — rejecting');
return $json;
Enter fullscreen mode Exit fullscreen mode

Three details matter here: verify against the raw body (re-serializing parsed JSON can change whitespace and break the HMAC), compare with timingSafeEqual (a plain === leaks the secret byte-by-byte to a patient attacker), and throw before anything else in the workflow touches the payload — no LLM call, no database write, no email.

Note the $env usage: the signing secret lives in an environment variable, not in the workflow JSON. Secrets in workflow JSON get exported, screenshotted, and committed to git. Ask me how I know.

The checklist I run now for every webhook workflow

  1. Webhook node has Header Auth enabled (or the caller uses HMAC signatures I verify in the first Code node)
  2. Signature check runs before any side effect, fails closed, uses timingSafeEqual on the raw body
  3. Signing secret in $env, never in the workflow JSON
  4. Building on the test URL; production URL only at ship time
  5. Webhook path treated as rotatable — changed immediately on any suspected leak
  6. No screenshots, exports, or logs containing a live production webhook URL

A webhook URL is a password that you paste into a browser address bar for a living. Treat it like one, and make the workflow distrust its caller until the signature checks out.


I'm building AI Automation Lab — production-ready n8n workflow templates. The free AI Hook Generator shows how I structure real workflows; my open-source templates live at github.com/zhp910318/n8n-ai-automation-lab.

Top comments (10)

Collapse
 
rushdeploy profile image
Rishabh Mishra •

The fail-closed-before-side-effects point is the one most tutorials skip. One more layer worth adding: rate-limit the webhook so a leaked URL can't be hammered while you rotate the path.

Collapse
 
haopengai profile image
HaoPeng Zhang •

Good call — and one ordering detail matters: rate-limit after signature verification, not before. If the limiter runs first, anyone holding the leaked URL can just burn through the bucket and DoS your legitimate callers. Same fail-closed instinct as the rest of the pipeline.

On where to put it in n8n: the Webhook node has no built-in rate limiting, so in practice it's one of three places — Cloudflare in front of your n8n domain (free-tier per-IP rules, cheapest if you already proxy through it), a small middleware or edge function with a Redis counter keyed by webhook path (the only one of the three that stays correct across multi-instance setups), or inside n8n itself counting per-sender in static data or a DB table and dropping over-limit executions (fine on a single instance, gets racy beyond that).

One more thing on rotation: rotate the path and the secret together. New path plus old secret means every previously captured signed payload still verifies.

Collapse
 
rushdeploy profile image
Rishabh Mishra •

That's a sharp ordering detail — rate-limiting before verification just hands the attacker a throttle against your real callers. And rotating path plus secret together makes sense, since a new path with the old secret keeps every previously captured payload valid.

Thread Thread
 
haopengai profile image
HaoPeng Zhang •

One detail I'd add on the limiter placement: once it's after verification, key the bucket on the verified sender identity, not the IP. With HMAC-verified webhooks you usually have a trustworthy sender key handy — the provider's delivery ID, an API key header, a tenant id. Bucket per that. Keying by IP brings the shared-fate problem back in another form: NAT'd offices and serverless egress ranges share one bucket, and one bad actor behind the same egress burns the quota for every legitimate caller behind it.

On rotation mechanics, there's a wrinkle with rotating the secret alone: you have in-flight legitimate events signed with the old secret, so you need a grace window where both old and new verify. During that window every captured payload is still replayable, so the only thing actually stopping replays is retiring the path — which means the old path has to die immediately when you detect the leak, not at the end of the grace window. Kill old path now, dual-verify during grace, drop old secret after.

Thread Thread
 
rushdeploy profile image
Rishabh Mishra •

One more layer for the grace window: store the provider's delivery ID and reject repeats, so replays fail even while both secrets still verify. That closes the replay hole without needing to retire the path first.

Thread Thread
 
haopengai profile image
HaoPeng Zhang •

Exactly right — and one storage detail makes this cheap to operate: TTL the dedup store at grace-window length plus a small clock-skew margin. A replay arriving after the old secret is dead fails verification anyway, so keeping IDs longer than the overlap buys nothing. That turns the store into a fixed-size rolling window instead of an ever-growing log — no cleanup cron needed.

Two failure modes worth handling explicitly:

  1. Providers that don't send a delivery ID. GitHub (X-GitHub-Delivery) and Stripe (event id) do, but plenty of smaller providers don't. Fallback: hash (timestamp-normalized payload + sender key) as the dedup key. Weaker — without a real ID, a natural retry and a malicious replay are indistinguishable — but it still kills accidental double-processing, which is the common case.

  2. The delivery ID is a replay guard, not an authentication layer. Once the secret is leaked, the attacker mints valid signatures and fresh delivery IDs, so dedup changes nothing against them. It only closes the replay window during rotation. Which is why rotation speed still matters — dedup buys you a calmer grace window, not immunity.

That last point is also why the keyed rate limit still sits behind verification: different threats, different layers.

Thread Thread
 
rushdeploy profile image
Rishabh Mishra •

The TTL sizing point is a good one — the dedup store stays small when it matches the grace window. For the hash fallback, I'd add logging whenever it triggers, so you can see which providers still lack real delivery IDs. Keyed rate limiting behind verification plus this dedup layer covers the replay window well.

Thread Thread
 
haopengai profile image
HaoPeng Zhang •

Good instinct — and the log line is most useful if it carries a counter, not just a fire-and-forget line. In the n8n Code node, increment a per-provider counter (static data or your metrics sink) every time the hash fallback triggers. The number you actually want is the fallback rate per provider, not raw hits: when a provider that used to trigger it goes quiet, that's your signal they started sending real delivery IDs, and you can tighten that path.

One more adjustment while the fallback is active: treat its dedup window as weaker evidence. A natural retry and a malicious replay hash identically, so for fallback-only senders I shorten the grace TTL and pair it with a stricter per-sender rate bucket. It doesn't close the hole — nothing does without a real ID — but it shrinks the blast radius of the weaker key.

And agreed on the layering: verification first, keyed rate limit second, dedup third. Forgery, hammering, replay — each answers a different threat, none substitutes for the others.

Collapse
 
richard_smith_154156d471ef profile image
Richard Smith •

The screenshot point hits hard — I've definitely blurred everything except the one thing that mattered. Easy habit to build, hard one to break.

Collapse
 
haopengai profile image
HaoPeng Zhang •

Ha — been there. The worst one is always the screenshot you almost checked.

One habit that fixed this for me: sanitize upstream, never at the screenshot. When I'm building something I'll demo, I seed the instance with obviously-fake credentials first (sk-test-deadbeef, not-a-real-password). Real secrets never touch the test system, so no amount of screenshots can leak anything. Manual blur is a last resort, not a plan — it's the kind of step you do carefully at 2pm and sloppily at 2am.

Second: a 30-second pre-publish pass. Before hitting publish I open every image at full size and ask "would I be fine if the unedited version of this leaked?" If the answer isn't yes, it gets re-captured, not re-blurred.