FlowKit

Securing a public n8n webhook: authentication, HMAC and best practices

Published 17 July 2026 · 6 min read

An n8n Webhook node generates a URL and exposes it on the internet — that's the whole point of the node. But "exposed on the internet" means exactly that: anyone with the URL can send a request, trigger the workflow, and consume whatever resources come with it. For a webhook that hits an LLM on every call, like the API in the RAG Assistant Pack, that can quickly turn into an OpenAI bill climbing for no good reason. Here's how to close that gap, using n8n's built-in tools and without unnecessary complexity.

What n8n exposes by default

Create a Webhook node, leave its Authentication field on None, and the generated URL (https://your-instance.app/webhook/some-identifier) answers whoever calls it with the right HTTP method. n8n makes that identifier hard to brute-force, but a hard-to-guess identifier isn't a secret in the strict sense: it can leak into an access log, a shared browser history, a screenshot dropped in Slack, or a plain copy-paste into the wrong channel.

The real red flag: the Webhook node's Authentication field defaults to None at creation. Nothing stops you from leaving it that way — n8n doesn't warn you. It's an active choice you have to make, not a box that's checked for you automatically.

The three built-in options on the Webhook node

Before writing a single line of code, the Webhook node offers three authentication mechanisms out of the box, right in its configuration panel.

Basic Auth

A username and password sent in the Authorization header. Simple to set up, but the weakest of the three options — reserve it for cases where the caller genuinely can't do anything else (a legacy third-party service, a quick manual test).

Header Auth: the sensible default

You define a header name (e.g. X-API-Key) and a secret value, stored in a dedicated n8n credential. n8n automatically rejects any request that doesn't include exactly that header with that value — before the workflow even starts. For the vast majority of use cases (your own frontend calling the webhook, an internal integration, a third party that accepts a custom header), Header Auth is the right default: fast to configure, robust enough, and the secret never travels in plain sight inside the URL. It's also the setting to enable when a landing page form posts its leads to your webhook — and if the page itself is still on your to-do list, LanderKit's landing page templates ship optimised forms you just point at n8n.

Generate the value with a proper tool (openssl rand -hex 32, never a hand-picked word), and store it like any other secret: in n8n's credential manager, never hardcoded in a node or committed to a Git repository.

JWT Auth

n8n verifies a signed JWT, either via a shared passphrase (HMAC) or a public key (RSA/ECDSA). Relevant when the caller already issues JWTs — an identity provider, your own application backend managing user sessions — and you'd rather have cryptographic verification than a static secret you'd need to rotate manually if it ever leaked. For a simple integration between two services you control, that's often more machinery than you need; Header Auth is enough in that case.

Going further: verifying an HMAC signature

Those three options protect a webhook you expose for others to call. The reverse problem also comes up: a webhook that receives notifications from a third-party service — Stripe, GitHub, Typeform — usually gives you no customizable authentication header on the sender's side. Instead, these services sign the request body with a shared secret key, in a header like Stripe-Signature or X-Hub-Signature-256.

Verifying that signature takes a Code node placed right after the Webhook, before any business logic runs:

const crypto = require('crypto');

const secret = $credentials.webhookSecret; // stored as a credential, never hardcoded
const signature = $request.headers['x-hub-signature-256'];
const payload = JSON.stringify($input.item.json.body);

const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

const valid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expected)
);

if (!valid) {
  throw new Error('Invalid signature');
}

return $input.all();

The detail that matters: crypto.timingSafeEqual instead of a plain === comparison. A standard string comparison stops at the first differing character, which leaks a timing signal an attacker could use to guess the signature byte by byte. timingSafeEqual compares in constant time, regardless of content. It's an applied-cryptography detail that's easy to miss — which is exactly why it's worth calling out here.

Wire this Code node to an explicit error branch (see our guide on error handling in n8n) rather than letting the exception bubble up as-is: a request with an invalid signature deserves a clean 403, not a 500 that looks like an internal bug.

Rate limiting: n8n won't do it for you

The Webhook node has no built-in rate limiting. If your webhook calls an LLM on every request — the case for the RAG Assistant Pack's question-answer API — correct authentication keeps strangers from calling the endpoint, but it doesn't protect against a legitimate caller misconfigured into a loop, or a credential that leaks despite your best efforts.

Two reasonable approaches, without bolting on an external service:

  • A counter in Supabase: on every call, increment a rate_limit row (key = caller IP or identifier, value = number of calls in the current window). An IF node at the top of the workflow cuts off requests past the threshold, returning a 429.
  • A limit at the reverse-proxy layer, if your n8n instance is self-hosted behind Nginx or Traefik (limit_req on Nginx, for example): this is the most effective layer, since it blocks the request before n8n even starts an execution — before any resource is consumed.

On n8n Cloud, only the first option is available: you don't control the network layer in front of the instance. It's one of the trade-offs worth weighing in our comparison, n8n self-hosted vs cloud.

Restricting by IP when you can

If the caller(s) have known fixed IPs — a partner, another internal service, an outbound webhook from a SaaS that publishes its IP range (Stripe, GitHub, and most large providers do) — an allowlist at the reverse-proxy or VPS firewall level adds a free, application-independent layer. It doesn't replace authentication (IPs can be spoofed in some network contexts), but it sharply cuts down the surface for random probing attempts.

The mistakes that cost you an evening

  • Authentication left on None "just for testing": the most common case by far. A test webhook quietly becomes a forgotten production webhook, exposed with nothing on it.
  • A secret hardcoded in the node instead of stored as a credential: it ends up in the workflow's JSON export, potentially shared or committed without anyone noticing.
  • Signature comparison with === instead of timingSafeEqual: a subtle timing flaw, invisible in testing, theoretically exploitable.
  • No rate limit on a webhook that calls an LLM: a bug on the caller's side, or a leaked credential, translates directly into euros on your OpenAI or Anthropic bill.
  • No logging of rejected calls: without a trace of failed authentication attempts, there's no way to spot an attack in progress. The same principle applies here as in our GDPR audit trail guide: what isn't logged doesn't exist when you need it.

A concrete case: securing the RAG Assistant Pack's API

The api-question-reponse-rag workflow from the RAG Assistant Pack (€119) exposes a POST /ask webhook that queries your document base and answers through an LLM — described in detail in our Supabase pgvector + n8n guide. Out of the box, this webhook has no authentication: a deliberate choice, so you can test it immediately with curl without configuring a credential before you've even seen the workflow run.

Before going to production, the steps are straightforward: open the POST /ask (Webhook) node, set Authentication to Header Auth, create a credential with an X-API-Key header and a randomly generated value, then update your frontend or calling script to include that header. Five minutes of configuration, and your document assistant is no longer reachable — or billable — by anyone on the internet.

Wrapping up

An unprotected n8n webhook isn't some exotic vulnerability — it's the default setting, and it stays that way until you change it. Header Auth covers the vast majority of cases in a few minutes; JWT Auth and HMAC verification step in for more specific needs. Add a rate limit as soon as an LLM is in the loop, and log what you reject. Everything else — network segmentation, IP allowlisting — reinforces an already solid foundation rather than replacing it.

FAQ

Frequently asked questions

Is n8n's Webhook node protected by default?

No. By default, the Webhook node's Authentication field is set to None: anyone who guesses or obtains the URL can call the webhook with no verification at all. n8n hides the URL behind a random identifier, but a random identifier is not a secret — it can leak into an access log, a shared browser history, or a careless copy-paste.

Header Auth or JWT Auth: which should I pick?

Header Auth is enough for the vast majority of cases: internal integrations, calls from your own frontend, a third party that accepts a custom header. Move to JWT Auth when the caller already issues signed tokens (an identity provider, your own application backend) and you want cryptographic verification instead of a shared secret you'd have to rotate manually.

Should I verify an HMAC signature on top of Header Auth?

It depends on who's calling the webhook. For a service you control, Header Auth is sufficient. For an inbound webhook from a third party that signs its payloads (Stripe, GitHub, Typeform), HMAC verification is the method those providers themselves recommend: it guarantees the request body wasn't tampered with in transit, which a static header alone doesn't prove.

Compliance & Audit Pack

€149