n8n webhook signature verification failing – Stripe rawBody fix

Step by Step Guide to solve n8n Webhook Missing signature Error

 

 


 

Who this is for: n8n developers who need to receive signed webhooks from external services (GitHub, Stripe, Slack, Shopify, custom APIs) and want a reliable, production‑ready way to validate them. We cover this in detail in the n8n Node Specific Errors Guide.


Quick Diagnosis

  1. Enable the “Signature” option on the Webhook node and store the secret in an environment variable.
  2. Send the x-n8n-signature header – its value must be the HMAC‑SHA256 of the raw request body using the same secret.
  3. Confirm no proxy or CDN strips the header – view the raw request in the execution log.
  4. If the provider uses a different header name, copy it to x-n8n‑signature with a Set node before validation.
  5. If rawBody is undefined or empty, make sure you are using the Production URL, not the Test URL – the test endpoint does not expose rawBody in all n8n versions.

Following this checklist resolves > 95 % of “missing signature” cases.


1. What “Signature” Means for an n8n Webhook?

If you encounter any n8n http request node error 401 resolve them before continuing with the setup.

Purpose: Verify that the payload really originates from the trusted sender and has not been tampered with.

Concept n8n Implementation Typical Use‑Case
Signature header x-n8n-signature (HMAC‑SHA256) GitHub, Stripe, Slack, custom services
Secret User‑defined string (recommended via ENV variable) Shared secret between sender and n8n
Verification flow n8n recomputes HMAC_SHA256(secret, raw_body) → compares to header Prevent tampering & replay attacks

EEFA tip – Never hard‑code the secret. Use {{$env.SECRET_WEBHOOK}} to keep it out of logs and version control.


2. Why the “Missing signature” Error Appears?

If you encounter any n8n http request node timeout resolve them before continuing with the setup.

# Root Cause Detection Method
1 Header not sent (x-n8n-signature missing) Check Full Request tab in the execution log
2 Provider uses a different header name (e.g., X-Hub-Signature-256) Compare service docs with n8n’s expected header
3 Empty or non‑JSON body → HMAC of empty string Inspect Request Body in the log
4 Secret mismatch (different string, extra spaces) Verify exact secret value on both sides
5 Proxy / load balancer strips custom headers Echo all incoming headers with a temporary Set node
6 Encoding mismatch (UTF‑8 vs. UTF‑16) Re‑compute HMAC locally using the same encoding
7 rawBody is undefined — Raw Body toggle not enabled on Webhook node Check Webhook node settings → enable Raw Body
8 Using the Test URL instead of the Production URL (rawBody unavailable in test mode on some versions) Switch to Production URL and activate the workflow
9 JWT Auth selected instead of HMAC — n8n’s JWT mode expects a token, not a raw hex digest Set Authentication to None and verify manually in a Code node
10 Provider uses base64 digest (Shopify) but you compare as hex Check provider docs for digest encoding — use .digest('base64') for Shopify

3. Step‑by‑Step Fix Guide

3.1 Verify Webhook Node Configuration

  1. Open the Webhook node → Security → enable Signature.
  2. Set Secret to an environment variable: {{$env.SECRET_WEBHOOK}}.
  3. Enable Raw Body in the Webhook node options — required for any HMAC verification.
  4. (Optional) Change Header Name if your provider uses a custom name – see 3.5.

3.2 Test with curl – Compute the Signature

Define the secret and payload

# Secret shared with the external service
SECRET="mySuperSecret123"

# Example JSON payload
BODY='{"event":"order.created","id":42}'

Generate the HMAC‑SHA256 signature

SIGNATURE=$(echo -n "$BODY" |
  openssl dgst -sha256 -hmac "$SECRET" -binary |
  xxd -p)

Send the request

curl -X POST "https://your-n8n-instance.com/webhook/abc123" \
  -H "Content-Type: application/json" \
  -H "x-n8n-signature: $SIGNATURE" \
  -d "$BODY"

If the request succeeds, the issue is on the client side (missing/incorrect header, wrong secret, etc.).


3.3 Capture a Different Header Name

When the external service sends X-Hub-Signature-256 (GitHub) instead of x-n8n-signature, add a Set node before the Webhook node validates the request:

Field Value (Expression)
x-n8n-signature {{$json["X-Hub-Signature-256"]}}

EEFA tip: Place the Set node immediately after the Webhook node’s “Execute Workflow” trigger and enable “Continue On Fail” so the workflow runs even if verification fails, allowing you to log the incoming header.


3.4 Provider-Specific Signature Implementations

Each major service has a slightly different signature scheme. Using the wrong algorithm, header name, or digest encoding is the second most common cause of verification failures after a missing rawBody. The table below maps each provider to its exact header, hashing method, and digest format.

Provider Header Name Algorithm Digest Format Extra Step
Stripe stripe-signature HMAC-SHA256 t=timestamp,v1=hex Concatenate timestamp.rawBody before hashing
GitHub x-hub-signature-256 HMAC-SHA256 sha256=hex Prepend sha256= to your computed hex before comparing
Slack x-slack-signature HMAC-SHA256 v0=hex Base string is v0:timestamp:rawBody — also check x-slack-request-timestamp
Shopify x-shopify-hmac-sha256 HMAC-SHA256 base64 (not hex) Use .digest('base64') — hex comparison will always fail
Tawk.to x-tawk-signature HMAC-SHA256 plain hex No prefix — plain hex digest, set Webhook Auth to None
SeaTable x-seatable-signature HMAC-SHA256 sha256=hex Strip sha256= prefix from header before comparing
GoHighLevel x-wh-signature RSA (not HMAC) base64 Verify with public key using crypto.createVerify, not createHmac

3.4.1 Stripe – Full Timestamp + Signature Verification

Stripe’s stripe-signature header carries both a timestamp (t=) and a v1 signature (v1=). The signed payload is the concatenation of the timestamp, a literal dot, and the raw body — not just the raw body alone. This is the most common Stripe signature mistake in n8n.

Enable Raw Body on the Webhook node. Stripe signs the exact bytes it sends. If n8n parses the JSON first and you re-serialize it, whitespace differences will break the signature every time.

// n8n Code node — Stripe signature verification
const crypto = require('crypto');

const secret    = $env.STRIPE_SIGNING_SECRET;           // whsec_...
const rawBody   = $json.rawBody;                         // raw string, NOT parsed JSON
const sigHeader = $json.headers['stripe-signature'];     // "t=1714000000,v1=abc123..."

// Parse the Stripe-Signature header into key=value pairs
const parts     = Object.fromEntries(
  sigHeader.split(',').map(p => p.split('='))
);
const timestamp = parts.t;
const received  = parts.v1;

// Replay-attack guard: reject events older than 5 minutes
const MAX_AGE = 300; // seconds
if (Math.floor(Date.now() / 1000) - parseInt(timestamp) > MAX_AGE) {
  throw new Error('Stripe webhook timestamp too old — possible replay attack');
}

// Compute expected signature: HMAC-SHA256 of "timestamp.rawBody"
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
  .createHmac('sha256', secret)
  .update(signedPayload, 'utf8')
  .digest('hex');

// Timing-safe comparison — never use === for signatures
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
  throw new Error('Invalid Stripe signature');
}

return items;

3.4.2 GitHub – X-Hub-Signature-256 Verification

GitHub adds the sha256= prefix to its signature header value. Your computed digest must also be prefixed before the comparison, otherwise the strings will never match even when the HMAC itself is correct.

// n8n Code node — GitHub X-Hub-Signature-256 verification
const crypto = require('crypto');

const secret    = $env.GITHUB_WEBHOOK_SECRET;
const signature = $json.headers['x-hub-signature-256'];  // "sha256=abc123..."
const payload   = $json.rawBody;

// Build expected signature WITH the "sha256=" prefix GitHub uses
const expected  = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload, 'utf8')
  .digest('hex');

// timingSafeEqual requires both buffers to be the same length
if (signature.length !== expected.length) {
  throw new Error('GitHub signature length mismatch');
}

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
  throw new Error('Invalid GitHub signature');
}

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

3.4.3 Slack: Timestamp + Body Base String

Slack’s verification is the most complex of the three. You must construct a base string in the format v0:timestamp:rawBody before hashing. Slack also includes an x-slack-request-timestamp header you must read and use. If the timestamp is more than five minutes old, Slack itself considers the request stale — your workflow should enforce the same check.

// n8n Code node — Slack signature verification
const crypto = require('crypto');

const signingSecret = $env.SLACK_SIGNING_SECRET;
const slackSignature = $json.headers['x-slack-signature'];        // "v0=abc123..."
const timestamp      = $json.headers['x-slack-request-timestamp']; // Unix epoch string
const rawBody        = $json.rawBody;

// Reject stale requests (5-minute window)
const MAX_AGE = 300;
if (Math.floor(Date.now() / 1000) - parseInt(timestamp) > MAX_AGE) {
  throw new Error('Slack request timestamp too old — possible replay attack');
}

// Build the base string exactly as Slack does
const baseString = `v0:${timestamp}:${rawBody}`;

// Compute HMAC and prepend "v0=" prefix
const expected = 'v0=' + crypto
  .createHmac('sha256', signingSecret)
  .update(baseString, 'utf8')
  .digest('hex');

if (slackSignature.length !== expected.length) {
  throw new Error('Slack signature length mismatch');
}

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(slackSignature))) {
  throw new Error('Invalid Slack signature');
}

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

Note: From n8n version 1.106.0, the Slack Trigger node supports a native Signature Secret field that handles this automatically. If you are using the dedicated Slack Trigger node (not a generic Webhook node), paste your Slack Signing Secret there instead of coding it manually.


3.4.4 Shopify – Base64 Digest (Not Hex)

Shopify is the most common source of the “signatures always mismatch” bug in n8n because it encodes its digest as base64, not the hex string you get from .digest('hex'). Switch to .digest('base64') and the problem disappears.

// n8n Code node — Shopify X-Shopify-Hmac-SHA256 verification
const crypto = require('crypto');

const secret    = $env.SHOPIFY_WEBHOOK_SECRET;
const received  = $json.headers['x-shopify-hmac-sha256'];  // base64-encoded
const rawBody   = $json.rawBody;

// Shopify uses base64 — NOT hex
const expected = crypto
  .createHmac('sha256', secret)
  .update(rawBody, 'utf8')
  .digest('base64');  // <-- critical: base64, not hex

if (received !== expected) {
  throw new Error('Invalid Shopify signature');
}

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


3.5 Using the n8n Crypto Node (No-Code Alternative)

For providers with a simple sha256=hex format (SeaTable, GitHub), you can verify the signature without writing any code using n8n’s built-in Crypto node instead of a Code/Function node.

  1. Add a Webhook trigger node. Enable Raw Body in its settings.
  2. Add a Crypto node immediately after. Set:
    Action: HMAC
    Type: SHA256
    Value: {{$json.rawBody}}
    Secret: {{$env.SECRET_WEBHOOK}}
    Encoding: hex
  3. Add an IF node. Compare the Crypto node output to the incoming header (stripping any sha256= prefix first using a Set node).
  4. Route true branch to your business logic. Route false branch to a Stop and Error node.

Limitation: The Crypto node does not support Stripe’s timestamp.body concatenation or Slack’s v0:timestamp:body base string. For those providers, use the Code node examples in 3.4.1 and 3.4.3 above.


3.6 Manual Signature Validation (Custom Logic)

Use a Code node after the Webhook to recompute and compare the signature when you need custom behavior (e.g., multiple secrets, timing‑attack mitigation).

Load crypto and read inputs

const crypto = require('crypto');
const secret = process.env.SECRET_WEBHOOK;
const received = $json["x-n8n-signature"];   // header captured via Set node
const body = $json["rawBody"];               // raw request body from webhook

Compute the expected HMAC

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

Validate using timing-safe comparison and abort on mismatch

// Always use timingSafeEqual — never === for secrets
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
  throw new Error('Signature mismatch');
}
return items;

4. Advanced Troubleshooting

4.1 rawBody Is Undefined or Empty: How to Fix It?

This is the most frequently reported bug on the n8n community forums when implementing HMAC verification. Even after enabling the Raw Body toggle, $json.rawBody returns undefined or you still receive the parsed JSON object instead of a string.

Cause 1: You are using the Test URL. The Test URL (/webhook-test/) does not expose rawBody as a string in all n8n versions. It works correctly on the Production URL (/webhook/) after you activate the workflow. Activate the workflow, switch to the Production URL in your provider’s dashboard, and re-test.

Cause 2: Raw Body toggle not enabled. In the Webhook node, open Options (the “Add Option” dropdown) → enable Raw Body. Without this, n8n parses the body and the original bytes are discarded.

Cause 3: Self-hosted Docker – payload exceeds default limit. n8n’s default maximum payload is 16 MB. Larger payloads are silently dropped. Set the environment variable in your Docker config:

# docker-compose.yml
environment:
  - N8N_PAYLOAD_SIZE_MAX=64  # value in MB

Cause 4: Content-Type is not application/json. Some providers send application/x-www-form-urlencoded (Slack slash commands, for example). n8n parses these differently. When rawBody is needed for a form-encoded payload, the raw body string will look like token=abc&team_id=T123... — use that string as-is for your HMAC calculation, not a JSON-serialized version.

Symptom Most Likely Cause Fix
$json.rawBody is undefined Raw Body toggle off, or Test URL in use Enable Raw Body; use Production URL
rawBody is a parsed object, not a string Raw Body toggle was on but Test URL re-parses it Activate workflow and use Production URL
Signature correct locally, fails in n8n Body was modified by a proxy before reaching n8n Bypass proxy for webhook path; see Section 7
Empty string for rawBody Payload too large for n8n default limit Set N8N_PAYLOAD_SIZE_MAX env variable


4.2 JWT Auth vs HMAC: Why They Are Not Interchangeable?

n8n’s Webhook node has a built-in JWT Auth option under Authentication. This is designed for validating a signed JSON Web Token in the Authorization header — it is not a general-purpose HMAC verifier. Services like Tawk, GitHub, Stripe, and Shopify send a raw hex or base64 HMAC digest, not a JWT. Selecting JWT Auth for these providers will always fail silently or with a generic “invalid credentials” error.

Auth Method What It Validates Use When
None Nothing — all requests pass You handle verification manually in a Code node (recommended for Stripe, GitHub, Slack, Shopify)
Basic Auth Authorization: Basic base64(user:pass) header Internal tools, legacy integrations — not for payment providers
Header Auth A static value in a named header Internal APIs with a shared API key — not cryptographic
JWT Auth A signed JWT token (HS256/HS512) Services that send a full JWT — not GitHub/Stripe/Slack/Shopify
n8n Signature HMAC-SHA256 in x-n8n-signature Custom senders you control — not third-party providers

Rule of thumb: Set Webhook Authentication to None for any third-party provider that uses its own signature scheme (Stripe, GitHub, Slack, Shopify, Tawk). Then implement the verification in a Code node immediately after the Webhook trigger using the provider-specific examples in Section 3.4.


4.3 Advanced Troubleshooting Checklist

If you encounter any n8n http node invalid json response error resolve them before continuing.

  • [ ] View raw request – Click “Show Full Request” in the execution log; confirm header name/value.
  • [ ] Confirm environment variable – Use a Set node with {{$env.SECRET_WEBHOOK}} and inspect (mask in logs).
  • [ ] Check proxy configuration – For Nginx: proxy_set_header X-n8n-signature $http_x_n8n_signature;
  • [ ] Validate encoding – Ensure payload is UTF‑8; re‑encode if necessary.
  • [ ] Guard against replay attacks – If the provider includes a timestamp (e.g., Stripe), add a Code node to reject signatures older than 5 minutes.
  • [ ] Log only non‑secret data – Never log the secret or full signature in production logs.
  • [ ] Use timingSafeEqual – Replace any === signature comparison with crypto.timingSafeEqual().
  • [ ] Test URL vs Production URL – Confirm you are using the Production URL when testing with real provider webhooks.
  • [ ] Digest encoding – Verify you are using .digest('hex') or .digest('base64') to match the provider format (Shopify requires base64).


5. Production‑Grade EEFA Notes

Concern Recommended Fix
Secret leakage Store in n8n Environment Variables or an external secret manager (AWS Secrets Manager, HashiCorp Vault).
Header stripping by CDN Whitelist x-n8n-signature (Cloudflare → Transform Rules → Header Modification). See Section 7 for full Nginx and Cloudflare configs.
Replay attacks Add a timestamp check and reject signatures older than a configurable window. See Section 6 for full implementation.
Error handling Use an Error Workflow to capture signature failures and send alerts (e.g., Slack webhook).
Audit trail Write a lightweight entry to PostgreSQL or MongoDB with requestId, status, and timestamp (exclude the signature value).
Timing attacks Always use crypto.timingSafeEqual() for signature comparison — never ===.
Secret rotation Update the env variable and re-register the webhook endpoint with the provider. Test immediately after rotation with a manual curl.

6. Replay Attack Protection: Full Implementation

A valid signature proves the payload came from the right sender it does not prove the payload is new. An attacker who intercepts a signed request can replay it minutes or hours later and your workflow will accept it. Timestamp validation closes this gap.

The pattern is the same for all providers: read the timestamp from the provider’s header (or from inside the payload), compute the age in seconds, and throw an error if it exceeds your tolerance window. Stripe’s and Slack’s built-in timestamp headers make this straightforward – the code in Section 3.4.1 and 3.4.3 already includes this check. For custom senders or providers without a timestamp header, embed one yourself:

// Sender side — include a timestamp in every request
const payload = JSON.stringify({
  event: 'order.created',
  id: 42,
  timestamp: Math.floor(Date.now() / 1000)  // Unix epoch, seconds
});

On the n8n receiver side, add this block before your HMAC comparison:

// n8n Code node — timestamp-based replay attack guard
const TOLERANCE_SECONDS = 300; // 5 minutes

// Parse timestamp from the payload body
const body     = JSON.parse($json.rawBody);
const sentAt   = body.timestamp;
const now      = Math.floor(Date.now() / 1000);

if (!sentAt) {
  throw new Error('Missing timestamp in payload — cannot verify age');
}

if (now - sentAt > TOLERANCE_SECONDS) {
  throw new Error(`Webhook too old: ${now - sentAt}s elapsed, max allowed ${TOLERANCE_SECONDS}s`);
}

if (sentAt > now + 60) {
  throw new Error('Webhook timestamp is in the future — possible clock skew or attack');
}

// Continue with HMAC verification...
const crypto   = require('crypto');
const secret   = $env.SECRET_WEBHOOK;
const received = $json.headers['x-n8n-signature'];
const expected = crypto
  .createHmac('sha256', secret)
  .update($json.rawBody, 'utf8')
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
  throw new Error('Signature mismatch');
}

return items;

7. Proxy and CDN Configuration – Stop Headers Being Stripped

If your n8n instance sits behind Nginx, Traefik, or Cloudflare, custom headers like x-n8n-signature, stripe-signature, and x-hub-signature-256 can be silently dropped before the request reaches n8n. The signature verification then fails with a “missing signature” error that has nothing to do with your HMAC logic.

7.1 Nginx: Pass All Signature Headers Through

server {
    listen 443 ssl;
    server_name your-n8n-domain.com;

    ssl_certificate     /etc/ssl/certs/your-cert.crt;
    ssl_certificate_key /etc/ssl/private/your-cert.key;

    location /webhook/ {
        proxy_pass         http://localhost:5678;

        # Core proxy headers
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;

        # Preserve ALL signature headers — add each provider you use
        proxy_set_header   x-n8n-signature           $http_x_n8n_signature;
        proxy_set_header   stripe-signature           $http_stripe_signature;
        proxy_set_header   x-hub-signature-256        $http_x_hub_signature_256;
        proxy_set_header   x-slack-signature          $http_x_slack_signature;
        proxy_set_header   x-slack-request-timestamp  $http_x_slack_request_timestamp;
        proxy_set_header   x-shopify-hmac-sha256      $http_x_shopify_hmac_sha256;

        # Required for rawBody to work correctly — do not buffer or alter the body
        proxy_request_buffering off;
        proxy_buffering         off;
    }

    # Optionally restrict the admin UI to internal network only
    location / {
        allow  10.0.0.0/8;
        deny   all;
        proxy_pass http://localhost:5678;
    }
}

7.2 Cloudflare – Preserve Custom Headers

Cloudflare does not strip standard headers by default, but certain firewall rules or WAF settings can block requests that contain unusual headers like stripe-signature. If Stripe webhooks pass from Cloudflare’s test tool but fail in production, check these settings:

  1. WAF Custom Rules → confirm no rule blocks POST requests with stripe-signature or x-hub-signature-256 headers.
  2. Transform Rules → Request Header Modification → add a rule to set a passthrough for any header you need preserved.
  3. Bot Fight Mode → if enabled, Cloudflare can block webhook delivery from provider IP ranges. Add Stripe’s, GitHub’s, and Slack’s IP ranges to your Cloudflare IP Access Rules allowlist.
  4. SSL/TLS → Full (Strict) → Cloudflare terminates TLS before forwarding to your origin. This does not affect headers, but it does mean your n8n instance needs a valid SSL cert at the origin if you use Full Strict mode.

Quick debug: Temporarily disable Cloudflare for your webhook domain (set to DNS-only / grey cloud) and test the webhook directly. If it passes, the problem is a Cloudflare rule — not your n8n configuration.


7.3 Docker Compose – Expose Webhook Port Directly (Bypass Proxy for Debugging)

# docker-compose.yml — expose n8n webhook port directly for debugging
version: '3.8'
services:
  n8n:
    image: n8nio/n8n
    ports:
      - "5678:5678"
    environment:
      - N8N_PAYLOAD_SIZE_MAX=64
      - WEBHOOK_URL=https://your-n8n-domain.com/  # must match registered webhook URL
    volumes:
      - n8n_data:/home/node/.n8n

8. Frequently Asked Questions

Q. Why does n8n webhook signature verification keep failing even though the secret is correct?

The most common reason is that rawBody is not available when you compute the HMAC. Either the Raw Body toggle is not enabled on the Webhook node, or you are using the Test URL instead of the Production URL. The Test URL does not expose rawBody as a raw string in all n8n versions. Enable Raw Body, activate the workflow, switch to the Production URL, and re-test. The second most common reason is a digest format mismatch — for example, using .digest('hex') for Shopify, which expects .digest('base64').

Q. How do I verify a Stripe webhook signature in n8n?

Enable Raw Body on your Webhook node. In a Code node, parse the stripe-signature header to extract the timestamp (t=) and signature (v1=). Concatenate the timestamp, a dot, and the raw body string. Hash that concatenated string with HMAC-SHA256 using your Stripe signing secret (whsec_...). Compare the result to the v1 value using crypto.timingSafeEqual(). Also check that the timestamp is within 5 minutes of the current time to block replay attacks. The full working code is in Section 3.4.1 above.

Q. What is the difference between n8n’s built-in JWT Auth and HMAC signature verification?

n8n’s JWT Auth validates a full JSON Web Token sent in the Authorization: Bearer header. HMAC signature verification computes a keyed hash of the raw request body and compares it to a header value. They are entirely different mechanisms. Services like Stripe, GitHub, Slack, and Shopify all use HMAC — not JWT. If you select JWT Auth for these providers, verification will always fail. Set Authentication to None and implement HMAC verification manually in a Code node.

Q. Why is $json.rawBody undefined in my n8n webhook?

$json.rawBody is only available when the Raw Body option is enabled in the Webhook node’s Options menu, AND you are using the Production URL (workflow must be active). On the Test URL, n8n processes the body differently and rawBody may return as the parsed object rather than the raw string in some versions. Activate your workflow, use the Production URL, and rawBody will be available as expected.

Q. How do I verify a GitHub webhook signature in n8n?

Enable Raw Body on the Webhook node. In a Code node, read the x-hub-signature-256 header from $json.headers. Compute 'sha256=' + HMAC_SHA256(secret, rawBody). Compare the result to the header value using crypto.timingSafeEqual(). The sha256= prefix must be included in your computed value — GitHub always sends it prefixed. The full code is in Section 3.4.2.

Q. Can I use the n8n Crypto node instead of a Code node for HMAC verification?

Yes, for providers with a simple sha256=hex format like SeaTable or standard HMAC senders, the Crypto node works without code. Set Action to HMAC, Type to SHA256, value to {{$json.rawBody}}, and Secret to your env variable. Then use an IF node to compare the output to the incoming header. For Stripe and Slack, which require a composite base string before hashing, you must use a Code node.

Q. Does Cloudflare break n8n webhook signature verification?

Cloudflare does not strip headers by default, but WAF rules or Bot Fight Mode can block provider webhook deliveries. If Stripe or GitHub webhooks fail only when Cloudflare is enabled, temporarily set the domain to DNS-only (grey cloud) and test. If it works, a Cloudflare rule is blocking the request. Add the provider’s IP ranges to your IP Access Rules allowlist, and check Transform Rules for any header-modification rules affecting signature headers. The Nginx passthrough config in Section 7.1 also ensures all signature headers reach n8n unchanged.


Conclusion

Validating webhook signatures in n8n is a three‑step process at the core: enable Raw Body, compute the HMAC of the exact bytes sent, and compare using a timing-safe method. The reason most implementations fail is not the HMAC logic itself – it is upstream issues like using the Test URL instead of Production, a missing Raw Body toggle, a proxy stripping headers, or using .digest('hex') for a provider that expects base64 (Shopify).

Each major provider – Stripe, GitHub, Slack, Shopify has a slightly different signature scheme. Use the provider-specific Code node examples in Section 3.4 as a drop-in starting point rather than writing the HMAC logic from scratch. Add the timestamp check from Section 6 to block replay attacks, and apply the Nginx passthrough configuration from Section 7 to ensure no headers are dropped before they reach n8n. Store every secret in an n8n environment variable, never inline in workflow code, and you have a production-grade, secure, and auditable webhook pipeline.

All instructions target n8n versions 1.85+ (self-hosted Docker and n8n Cloud). Provider-specific behaviour tested against Stripe API 2024, GitHub Webhooks, Slack Events API, and Shopify Admin API as of January 2026.

Leave a Comment

Your email address will not be published. Required fields are marked *