teachyou.ai academy
← All posts
n8n

n8n Webhook Security: Verifying Incoming Requests

Pramod Dutta · Jun 4, 2026 · 14 min read

Your Webhook URL Is Not a Secret

Here's an uncomfortable truth about n8n automations: the moment you expose a webhook trigger to the internet, anyone who finds that URL can send it a request. It doesn't matter that the URL looks random — https://your-instance.app.n8n.cloud/webhook/8f3a2e1c-... isn't a password, it's a public endpoint. URLs get logged by proxies, leaked in browser history, cached by CDNs, pasted into Slack channels, and occasionally scraped by bots that crawl for exposed automation endpoints. If your workflow trusts every payload that hits that URL, you've built an open door into whatever that workflow touches — your database, your CRM, your payment processor, your inbox.

This matters more in n8n specifically because webhook-triggered workflows are often wired directly into powerful actions: creating rows in Postgres, sending emails, hitting internal APIs with service credentials, or triggering AI agents that execute tool calls. A forged request that looks like a legitimate GitHub push event, a Stripe payment notification, or a Shopify order webhook can cause real damage if your workflow doesn't verify where it actually came from.

The good news is that securing an n8n webhook is a solved problem. Every serious webhook provider — Stripe, GitHub, Shopify, Slack, Razorpay — signs its payloads with HMAC, and n8n gives you the building blocks (the Webhook node, the Crypto node, Code nodes, IF nodes) to verify those signatures properly. This article walks through exactly how to do that: the theory behind HMAC verification, working JavaScript you can drop into a Code node, the mistakes that quietly defeat signature checks, and the layered defenses — timestamps, replay protection, IP allowlisting — that turn a webhook trigger from a liability into a hardened entry point.

How HMAC Signature Verification Actually Works

Almost every webhook security scheme follows the same pattern, whether it's Stripe, GitHub, or a custom internal service:

  1. The sender and receiver share a secret key, agreed upon out-of-band (usually copy-pasted from a dashboard into an environment variable).
  2. When the sender fires a webhook, it computes an HMAC (Hash-based Message Authentication Code) over the raw request body using that shared secret and a hash function like SHA-256.
  3. The resulting signature is sent as a header — commonly X-Signature, X-Hub-Signature-256, or a provider-specific name like Stripe-Signature.
  4. The receiver (your n8n workflow) recomputes the HMAC independently, using the same secret and the same raw bytes it received, and compares the two signatures.
  5. If they match, the payload provably came from someone who knows the secret and wasn't altered in transit. If they don't match, reject the request.

The security guarantee here isn't encryption — the payload is still readable by anyone who intercepts it. HMAC gives you authenticity and integrity: proof of origin and proof of no tampering. That's exactly what you need for a webhook, since the payload itself (an order ID, a payment status, a GitHub commit SHA) usually isn't secret, but you absolutely need to know it's genuine before acting on it.

The critical detail that trips people up is "the same raw bytes." HMAC is computed over the exact byte sequence of the request body — not a parsed-and-reserialized JSON object. If n8n (or any framework) parses the incoming JSON, reformats it, and then you compute HMAC over JSON.stringify(parsedBody), you will get a different signature than the sender computed, even if the data is logically identical. Key ordering, whitespace, and number formatting can all change during parse-and-restringify. This is the single most common reason "signature verification" fails in production despite being implemented — the raw body was never actually raw.

Setting Up the Webhook Node to Preserve Raw Body

In n8n, the default Webhook node parses incoming JSON automatically, which throws away the raw body you need for verification. You have two reliable options:

Option 1: Use "Raw Body" mode on the Webhook node. In recent n8n versions, the Webhook node has an option under "Response" / "Options" to enable raw body parsing, or you can set the binary data mode so the body arrives untouched. Check the node's options panel for a "Raw Body" toggle — when enabled, the body is available as a binary buffer on the input item, which you can convert to a string in a Code node without any re-serialization.

Option 2: Front the webhook with a lightweight proxy (an API Gateway, Cloudflare Worker, or small Express service) that captures the raw body, verifies the signature there, and only forwards validated requests to n8n. This adds infrastructure but keeps verification logic outside the workflow entirely — useful if you have many workflows that all need the same provider's verification.

For most teams, Option 1 is simpler and keeps everything inside n8n. The rest of this article assumes you have the raw request body available as a string in your Code node.

Writing the HMAC Verification Code Node

Here's a complete, working verification function for a generic HMAC-SHA256 scheme (the pattern used by GitHub, Shopify, and many custom integrations). Drop this into a Code node placed immediately after your Webhook node:

// Code node: Verify HMAC Signature
const crypto = require('crypto');

const SECRET = $env.WEBHOOK_SECRET; // stored in n8n environment variables

// Raw body must be the exact bytes received — not re-stringified JSON
const rawBody = $input.first().binary?.data
  ? Buffer.from($input.first().binary.data.data, 'base64').toString('utf8')
  : $input.first().json.rawBody;

const receivedSignature = $input.first().json.headers['x-signature-256'];

if (!rawBody || !receivedSignature) {
  throw new Error('Missing raw body or signature header');
}

// Compute expected signature
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(rawBody, 'utf8');
const expectedSignature = 'sha256=' + hmac.digest('hex');

// Timing-safe comparison — never use === or plain string compare
function timingSafeEqual(a, b) {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');

  if (bufA.length !== bufB.length) {
    // Still run a comparison to avoid leaking length via timing
    crypto.timingSafeEqual(bufA, bufA);
    return false;
  }

  return crypto.timingSafeEqual(bufA, bufB);
}

const isValid = timingSafeEqual(receivedSignature, expectedSignature);

if (!isValid) {
  throw new Error('Signature verification failed');
}

return [{ json: { ...$input.first().json, verified: true } }];

A few details worth calling out explicitly:

  • `crypto.timingSafeEqual` is not optional. A naive receivedSignature === expectedSignature comparison short-circuits on the first mismatched character, which means the comparison for a signature that matches the first 10 characters takes measurably longer than one that fails immediately. Over enough requests, an attacker can use that timing difference to guess the correct signature one byte at a time. This is a real, documented class of attack — not theoretical paranoia — and it's why every serious HMAC implementation uses a constant-time comparison function.
  • Length must be checked before calling `timingSafeEqual`, because Node's implementation throws if the two buffers aren't the same length rather than returning false. The pattern above runs a dummy comparison against itself when lengths differ, just to keep the timing profile consistent even in that branch.
  • The secret comes from an environment variable, never hardcoded in the node and never stored in a workflow parameter that gets exported with the workflow JSON. n8n workflows are frequently shared, duplicated, or committed to version control — a secret embedded directly in a node will leak.

Handling Provider-Specific Signature Formats

Different providers format their signature headers differently, and getting this wrong is the second most common cause of "valid" webhooks being rejected. Here's how a few common ones actually look, and the adjustments each requires:

  • GitHub sends X-Hub-Signature-256: sha256=<hex digest>. The code above matches this format directly.
  • Stripe sends Stripe-Signature: t=1614556800,v1=<hex digest> and expects you to build the signed payload as timestamp + "." + rawBody before hashing — not the raw body alone. Skipping the timestamp concatenation is a common bug that makes Stripe verification silently fail.
  • Shopify sends X-Shopify-Hmac-SHA256 as base64, not hex. You need hmac.digest('base64') instead of hmac.digest('hex').
  • Razorpay (relevant if you're processing Indian payments) sends X-Razorpay-Signature as a hex HMAC-SHA256 over the raw body, similar to GitHub's scheme.

The lesson: always check the exact provider documentation for (a) which header carries the signature, (b) whether it's hex or base64 encoded, and (c) whether the signed content is the raw body alone or a composite string like timestamp.body. Here's a Stripe-style verification adapted from the base pattern:

// Code node: Verify Stripe-style signature with timestamp binding
const crypto = require('crypto');

const SECRET = $env.STRIPE_WEBHOOK_SECRET;
const rawBody = $input.first().json.rawBody;
const sigHeader = $input.first().json.headers['stripe-signature'];

// Parse "t=1614556800,v1=abcdef..." into a map
const parts = Object.fromEntries(
  sigHeader.split(',').map(kv => kv.split('='))
);

const timestamp = parts.t;
const receivedSig = parts.v1;

const signedPayload = `${timestamp}.${rawBody}`;
const expectedSig = crypto
  .createHmac('sha256', SECRET)
  .update(signedPayload, 'utf8')
  .digest('hex');

const sigMatches = crypto.timingSafeEqual(
  Buffer.from(receivedSig, 'utf8'),
  Buffer.from(expectedSig, 'utf8')
);

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

return [{ json: { verified: true, timestamp } }];

Notice the timestamp variable extracted here — that's the hook for the next layer of defense: replay protection.

Replay Protection: Why Signature Verification Alone Isn't Enough

A valid HMAC signature proves the request came from someone who knows the secret at some point in time. It does not prove the request is happening *now*. If an attacker intercepts a legitimate, correctly-signed webhook payload — via a compromised proxy, a logging system, a browser extension, or a man-in-the-middle on an unencrypted link — they can replay that exact request later, and your signature check will happily pass it again, because the signature is still mathematically valid.

This matters a lot for financial or state-changing webhooks. Imagine a payment-confirmation webhook that triggers "mark order as paid and ship it." If that request is captured once and replayed ten times, and your workflow has no replay protection, you could end up shipping ten orders for one payment.

The standard defense is a timestamp check combined with the signature:

  1. Require the sender to include a timestamp in the signed payload (Stripe does this natively, as shown above).
  2. Reject any request where the timestamp is more than a small tolerance window (typically 5 minutes) away from the current server time.
  3. Optionally, track a short-lived cache of recently-seen signatures or event IDs and reject exact duplicates within the tolerance window, guarding against replay within the valid time window itself.

Here's how to add the tolerance check in n8n using an IF node or inline in the Code node:

// Add this after signature verification succeeds
const nowSeconds = Math.floor(Date.now() / 1000);
const requestTimestamp = parseInt(timestamp, 10);
const TOLERANCE_SECONDS = 300; // 5 minutes

if (Math.abs(nowSeconds - requestTimestamp) > TOLERANCE_SECONDS) {
  throw new Error('Webhook timestamp outside tolerance window — possible replay');
}

For providers that don't include a timestamp in the signed payload at all (a surprising number of smaller SaaS tools don't), you can't fully solve replay at the signature layer. In that case, fall back to idempotency: extract a unique event ID from the payload (most providers include one, like event.id or delivery_id) and check it against a small store — a Postgres table, Redis set, or even an n8n data table — before processing. If the ID has already been processed, short-circuit and return a 200 without re-running side effects. This is good practice even when timestamp validation is present, since it protects against legitimate provider retries causing duplicate processing, not just malicious replay.

IP Allowlisting as a Second Layer

Signature verification should be your primary control, but IP allowlisting is a cheap, effective second layer that stops a large class of noise before it even reaches your verification logic — scanner bots, credential-stuffing attempts on the webhook path, and misconfigured clients hitting the wrong URL.

Most major providers publish a stable list of source IP ranges for their webhook infrastructure (GitHub, Stripe, and Shopify all do). If you're running n8n behind a reverse proxy (nginx, Cloudflare, an API Gateway), the cleanest place to enforce an IP allowlist is at that proxy layer, before the request ever reaches n8n. This keeps the check out of your workflow logic and off n8n's compute.

If you don't control a proxy layer and need to do it inside the workflow itself, you can inspect the forwarded IP header and compare against an allowlist in a Code node:

// Code node: IP allowlist check (use only if no proxy-level filtering exists)
const ALLOWED_CIDRS = ['192.30.252.0/22', '185.199.108.0/22']; // example ranges

function ipInCidr(ip, cidr) {
  const [range, bits] = cidr.split('/');
  const mask = ~(2 ** (32 - Number(bits)) - 1);

  const ipToInt = (addr) =>
    addr.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0);

  return (ipToInt(ip) & mask) === (ipToInt(range) & mask);
}

const requestIp = $input.first().json.headers['x-forwarded-for']?.split(',')[0].trim();

const isAllowed = ALLOWED_CIDRS.some(cidr => ipInCidr(requestIp, cidr));

if (!isAllowed) {
  throw new Error(`Request from disallowed IP: ${requestIp}`);
}

Treat IP allowlisting as defense-in-depth, not a substitute for signature verification. IP ranges change, providers add new regions, and X-Forwarded-For headers can be spoofed if your proxy isn't configured to strip and re-set them correctly. Never rely on IP filtering alone for anything that triggers a state change.

Common Mistakes That Silently Break Webhook Security

A few patterns show up repeatedly in n8n workflows that "have" signature verification but don't actually get any security benefit from it:

  • Verifying against the parsed JSON body instead of the raw body. As covered earlier, this produces mismatched signatures for legitimate requests (forcing teams to "fix" it by disabling verification) or, worse, happens to work for simple flat payloads but breaks silently once nested objects or number formatting changes.
  • Comparing signatures with `==` or `===`. This reintroduces the timing side-channel that HMAC comparison functions exist to prevent.
  • Storing the webhook secret in the workflow JSON rather than in n8n's environment variables or credentials system. Exported/shared workflows leak the secret along with the automation logic.
  • Trusting the `Content-Type` header or payload shape to identify the sender, instead of verifying a cryptographic signature. If your IF node branches based on "does this look like a Stripe payload," an attacker only needs to shape a request to look right — there's no cryptographic barrier at all.
  • Failing open instead of failing closed. If the Code node's error handling is misconfigured and a thrown exception gets caught and swallowed somewhere downstream (a Try/Catch wrapper that logs and continues, for instance), a failed verification might still let the workflow proceed. Always confirm that a verification failure halts the workflow and returns an error response — test this explicitly by sending a request with a deliberately wrong signature and confirming the workflow stops.
  • No monitoring on verification failures. A spike in failed signature checks is a signal worth alerting on — it often means someone is probing your webhook endpoint. Route verification failures to a logging step or an alert (email, Slack) rather than just silently rejecting them.

Putting It Together: A Hardened Webhook Workflow Structure

A production-grade n8n webhook workflow for anything that matters — payments, order data, user-triggered automations with side effects — should follow this shape:

  1. Webhook node with raw body preserved, restricted to POST, with authentication left off (since HMAC replaces basic auth as your actual security control) but placed behind HTTPS only — never accept plaintext HTTP for a webhook carrying a signature, since the signature protects integrity but not confidentiality, and some payloads do contain sensitive fields.
  2. IP allowlist check (proxy-level if possible, Code node if not) as a cheap first filter.
  3. HMAC signature verification using the provider's exact scheme, with timing-safe comparison.
  4. Timestamp / replay check against a tolerance window, plus event-ID based idempotency for anything that triggers a side effect.
  5. Error branch that logs failures with enough context (source IP, timestamp, which check failed) to detect probing patterns, without logging the secret itself.
  6. Business logic only runs after all four gates pass.

This structure adds maybe 15–20 lines of Code node logic and one extra node to a workflow, and it's the difference between a webhook trigger that trusts the internet and one that verifies it.

Wrapping Up

Webhook security in n8n isn't complicated once you understand the shape of the problem: prove authenticity with HMAC, compare signatures in constant time, bind requests to a time window to stop replay, and add IP filtering as a cheap extra layer. The code patterns above cover the vast majority of providers you'll integrate with — GitHub, Stripe, Shopify, Razorpay, and any custom internal service that follows the same HMAC convention. The mistakes that undermine these defenses are almost always subtle — a re-serialized body, a non-constant-time comparison, a secret sitting in exported workflow JSON — which is exactly why it's worth building these checks once, carefully, and reusing them as a sub-workflow across every webhook trigger you own.

If you're building AI-driven automations in n8n — agents that call tools, process incoming events, and take autonomous action — webhook security becomes even more important, since a forged trigger can cause an agent to act on attacker-controlled input. If you want to go deeper into building secure, production-grade automations with n8n and AI agents, check out the n8n AI Agent Tutorial course on teachyou.ai, where we cover this kind of hardening alongside the broader architecture of reliable agentic workflows.