<p><img class="alignnone size-full wp-image-4372" src="https://flowgenius.in/wp-content/uploads/2026/01/Child-4-Cluster-9.png" alt="" /></p>
<p style="text-align: center;">Step by Step Guide to solve n8n Webhook Missing signature Error</p>
<p> </p>
<p> </p>
<hr />
<p> </p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Who this is for:</strong> 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. <strong>We cover this in detail in the</strong> <a href="https://flowgenius.in/n8n-node-errors-troubleshooting/">n8n Node Specific Errors Guide.</a></p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">Quick Diagnosis</h2>
<ol style="margin-bottom: 2em; line-height: 1.9;">
<li><strong>Enable the “Signature” option</strong> on the Webhook node and store the secret in an environment variable.</li>
<li><strong>Send the <code>x-n8n-signature</code> header</strong> – its value must be the HMAC‑SHA256 of the raw request body using the same secret.</li>
<li><strong>Confirm no proxy or CDN strips the header</strong> – view the raw request in the execution log.</li>
<li><strong>If the provider uses a different header name</strong>, copy it to <code>x-n8n‑signature</code> with a Set node before validation.</li>
<li><strong>If <code>rawBody</code> is undefined or empty</strong>, make sure you are using the <strong>Production URL</strong>, not the Test URL – the test endpoint does not expose rawBody in all n8n versions.</li>
</ol>
<p style="margin-bottom: 2em; line-height: 1.9;">Following this checklist resolves > 95 % of “missing signature” cases.</p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">1. What “Signature” Means for an n8n Webhook?</h2>
<p><strong>If you encounter any</strong> <a href="https://flowgenius.in/n8n-http-request-node-error-401">n8n http request node error 401</a><strong> resolve them before continuing with the setup.</strong></p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Purpose</strong>: Verify that the payload really originates from the trusted sender and has not been tampered with.</p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Concept</th>
<th style="border: 1px solid #ddd; padding: 12px;">n8n Implementation</th>
<th style="border: 1px solid #ddd; padding: 12px;">Typical Use‑Case</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Signature header</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-n8n-signature</code> (HMAC‑SHA256)</td>
<td style="border: 1px solid #ddd; padding: 12px;">GitHub, Stripe, Slack, custom services</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Secret</td>
<td style="border: 1px solid #ddd; padding: 12px;">User‑defined string (recommended via <strong>ENV variable</strong>)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Shared secret between sender and n8n</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Verification flow</td>
<td style="border: 1px solid #ddd; padding: 12px;">n8n recomputes <code>HMAC_SHA256(secret, raw_body)</code> → compares to header</td>
<td style="border: 1px solid #ddd; padding: 12px;">Prevent tampering & replay attacks</td>
</tr>
</tbody>
</table>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>EEFA tip</strong> – Never hard‑code the secret. Use <code>{{$env.SECRET_WEBHOOK}}</code> to keep it out of logs and version control.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">2. Why the “Missing signature” Error Appears?</h2>
<p><strong>If you encounter any</strong> <a href="https://flowgenius.in/n8n-http-request-node-timeout">n8n http request node timeout</a><strong> resolve them before continuing with the setup.</strong></p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">#</th>
<th style="border: 1px solid #ddd; padding: 12px;">Root Cause</th>
<th style="border: 1px solid #ddd; padding: 12px;">Detection Method</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">1</td>
<td style="border: 1px solid #ddd; padding: 12px;">Header not sent (<code>x-n8n-signature</code> missing)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Check <strong>Full Request</strong> tab in the execution log</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">2</td>
<td style="border: 1px solid #ddd; padding: 12px;">Provider uses a different header name (e.g., <code>X-Hub-Signature-256</code>)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Compare service docs with n8n’s expected header</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">3</td>
<td style="border: 1px solid #ddd; padding: 12px;">Empty or non‑JSON body → HMAC of empty string</td>
<td style="border: 1px solid #ddd; padding: 12px;">Inspect <strong>Request Body</strong> in the log</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">4</td>
<td style="border: 1px solid #ddd; padding: 12px;">Secret mismatch (different string, extra spaces)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Verify exact secret value on both sides</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">5</td>
<td style="border: 1px solid #ddd; padding: 12px;">Proxy / load balancer strips custom headers</td>
<td style="border: 1px solid #ddd; padding: 12px;">Echo all incoming headers with a temporary Set node</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">6</td>
<td style="border: 1px solid #ddd; padding: 12px;">Encoding mismatch (UTF‑8 vs. UTF‑16)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Re‑compute HMAC locally using the same encoding</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">7</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>rawBody</code> is <code>undefined</code> — Raw Body toggle not enabled on Webhook node</td>
<td style="border: 1px solid #ddd; padding: 12px;">Check Webhook node settings → enable <strong>Raw Body</strong></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">8</td>
<td style="border: 1px solid #ddd; padding: 12px;">Using the <strong>Test URL</strong> instead of the Production URL (rawBody unavailable in test mode on some versions)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Switch to Production URL and activate the workflow</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">9</td>
<td style="border: 1px solid #ddd; padding: 12px;">JWT Auth selected instead of HMAC — n8n’s JWT mode expects a token, not a raw hex digest</td>
<td style="border: 1px solid #ddd; padding: 12px;">Set Authentication to <strong>None</strong> and verify manually in a Code node</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">10</td>
<td style="border: 1px solid #ddd; padding: 12px;">Provider uses base64 digest (Shopify) but you compare as hex</td>
<td style="border: 1px solid #ddd; padding: 12px;">Check provider docs for digest encoding — use <code>.digest('base64')</code> for Shopify</td>
</tr>
</tbody>
</table>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">3. Step‑by‑Step Fix Guide</h2>
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.1 Verify Webhook Node Configuration</h3>
<ol style="margin-bottom: 2em; line-height: 1.9;">
<li>Open the <strong>Webhook</strong> node → <strong>Security</strong> → enable <strong>Signature</strong>.</li>
<li>Set <strong>Secret</strong> to an environment variable: <code>{{$env.SECRET_WEBHOOK}}</code>.</li>
<li>Enable <strong>Raw Body</strong> in the Webhook node options — required for any HMAC verification.</li>
<li>(Optional) Change <strong>Header Name</strong> if your provider uses a custom name – see <strong>3.5</strong>.</li>
</ol>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.2 Test with <code>curl</code> – Compute the Signature</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Define the secret and payload</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;"># Secret shared with the external service
SECRET="mySuperSecret123"
# Example JSON payload
BODY='{"event":"order.created","id":42}'
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;">Generate the HMAC‑SHA256 signature</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">SIGNATURE=$(echo -n "$BODY" |
openssl dgst -sha256 -hmac "$SECRET" -binary |
xxd -p)
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;">Send the request</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">curl -X POST "https://your-n8n-instance.com/webhook/abc123" \
-H "Content-Type: application/json" \
-H "x-n8n-signature: $SIGNATURE" \
-d "$BODY"
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;">If the request succeeds, the issue is on the client side (missing/incorrect header, wrong secret, etc.).</p>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.3 Capture a Different Header Name</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">When the external service sends <code>X-Hub-Signature-256</code> (GitHub) instead of <code>x-n8n-signature</code>, add a <strong>Set</strong> node <em>before</em> the Webhook node validates the request:</p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Field</th>
<th style="border: 1px solid #ddd; padding: 12px;">Value (Expression)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-n8n-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>{{$json["X-Hub-Signature-256"]}}</code></td>
</tr>
</tbody>
</table>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>EEFA tip:</strong> Place the Set node <strong>immediately after</strong> 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.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<p><!-- ============================================================ NEW SECTION 3.4 — Provider-Specific Signature Guides ============================================================ --></p>
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.4 Provider-Specific Signature Implementations</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">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 <code>rawBody</code>. The table below maps each provider to its exact header, hashing method, and digest format.</p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Provider</th>
<th style="border: 1px solid #ddd; padding: 12px;">Header Name</th>
<th style="border: 1px solid #ddd; padding: 12px;">Algorithm</th>
<th style="border: 1px solid #ddd; padding: 12px;">Digest Format</th>
<th style="border: 1px solid #ddd; padding: 12px;">Extra Step</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Stripe</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>stripe-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>t=timestamp,v1=hex</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Concatenate <code>timestamp.rawBody</code> before hashing</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">GitHub</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-hub-signature-256</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>sha256=hex</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Prepend <code>sha256=</code> to your computed hex before comparing</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Slack</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-slack-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>v0=hex</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Base string is <code>v0:timestamp:rawBody</code> — also check <code>x-slack-request-timestamp</code></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Shopify</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-shopify-hmac-sha256</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;"><strong>base64</strong> (not hex)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Use <code>.digest('base64')</code> — hex comparison will always fail</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Tawk.to</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-tawk-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;">plain hex</td>
<td style="border: 1px solid #ddd; padding: 12px;">No prefix — plain hex digest, set Webhook Auth to <strong>None</strong></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">SeaTable</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-seatable-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>sha256=hex</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Strip <code>sha256=</code> prefix from header before comparing</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">GoHighLevel</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>x-wh-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">RSA (not HMAC)</td>
<td style="border: 1px solid #ddd; padding: 12px;">base64</td>
<td style="border: 1px solid #ddd; padding: 12px;">Verify with public key using <code>crypto.createVerify</code>, not <code>createHmac</code></td>
</tr>
</tbody>
</table>
<hr style="margin: 55px 0;" />
<h4 style="margin-bottom: 20px; line-height: 1.3;">3.4.1 Stripe – Full Timestamp + Signature Verification</h4>
<p style="margin-bottom: 2em; line-height: 1.9;">Stripe’s <code>stripe-signature</code> header carries both a timestamp (<code>t=</code>) and a v1 signature (<code>v1=</code>). 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.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Enable Raw Body on the Webhook node.</strong> 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.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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;
</pre>
<hr style="margin: 55px 0;" />
<h4 style="margin-bottom: 20px; line-height: 1.3;">3.4.2 GitHub – X-Hub-Signature-256 Verification</h4>
<p style="margin-bottom: 2em; line-height: 1.9;">GitHub adds the <code>sha256=</code> 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.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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 } }];
</pre>
<hr style="margin: 55px 0;" />
<h4 style="margin-bottom: 20px; line-height: 1.3;">3.4.3 Slack: Timestamp + Body Base String</h4>
<p style="margin-bottom: 2em; line-height: 1.9;">Slack’s verification is the most complex of the three. You must construct a base string in the format <code>v0:timestamp:rawBody</code> before hashing. Slack also includes an <code>x-slack-request-timestamp</code> 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.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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 } }];
</pre>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>Note:</strong> From n8n version 1.106.0, the Slack Trigger node supports a native <strong>Signature Secret</strong> 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.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<h4 style="margin-bottom: 20px; line-height: 1.3;">3.4.4 Shopify – Base64 Digest (Not Hex)</h4>
<p style="margin-bottom: 2em; line-height: 1.9;">Shopify is the most common source of the “signatures always mismatch” bug in n8n because it encodes its digest as <strong>base64</strong>, not the hex string you get from <code>.digest('hex')</code>. Switch to <code>.digest('base64')</code> and the problem disappears.</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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 } }];
</pre>
<p><!-- ============================================================ NEW SECTION 3.5 — Using the n8n Crypto Node (no-code alternative) ============================================================ --></p>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.5 Using the n8n Crypto Node (No-Code Alternative)</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">For providers with a simple <code>sha256=hex</code> format (SeaTable, GitHub), you can verify the signature without writing any code using n8n’s built-in <strong>Crypto</strong> node instead of a Code/Function node.</p>
<ol style="margin-bottom: 2em; line-height: 1.9;">
<li>Add a <strong>Webhook</strong> trigger node. Enable <strong>Raw Body</strong> in its settings.</li>
<li>Add a <strong>Crypto</strong> node immediately after. Set:<br />
<strong>Action:</strong> HMAC<br />
<strong>Type:</strong> SHA256<br />
<strong>Value:</strong> <code>{{$json.rawBody}}</code><br />
<strong>Secret:</strong> <code>{{$env.SECRET_WEBHOOK}}</code><br />
<strong>Encoding:</strong> hex</li>
<li>Add an <strong>IF</strong> node. Compare the Crypto node output to the incoming header (stripping any <code>sha256=</code> prefix first using a <strong>Set</strong> node).</li>
<li>Route <strong>true</strong> branch to your business logic. Route <strong>false</strong> branch to a <strong>Stop and Error</strong> node.</li>
</ol>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>Limitation:</strong> The Crypto node does not support Stripe’s <code>timestamp.body</code> concatenation or Slack’s <code>v0:timestamp:body</code> base string. For those providers, use the Code node examples in 3.4.1 and 3.4.3 above.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">3.6 Manual Signature Validation (Custom Logic)</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Use a <strong>Code</strong> node after the Webhook to recompute and compare the signature when you need custom behavior (e.g., multiple secrets, timing‑attack mitigation).</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Load crypto and read inputs</strong></p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">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
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Compute the expected HMAC</strong></p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">const expected = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Validate using timing-safe comparison and abort on mismatch</strong></p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// Always use timingSafeEqual — never === for secrets
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
throw new Error('Signature mismatch');
}
return items;
</pre>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">4. Advanced Troubleshooting</h2>
<p><!-- ============================================================ NEW SECTION 4.1 — rawBody undefined / empty rawBody bug ============================================================ --></p>
<h3 style="margin-bottom: 25px; line-height: 1.3;">4.1 rawBody Is Undefined or Empty: How to Fix It?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">This is the most frequently reported bug on the n8n community forums when implementing HMAC verification. Even after enabling the <strong>Raw Body</strong> toggle, <code>$json.rawBody</code> returns <code>undefined</code> or you still receive the parsed JSON object instead of a string.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Cause 1: You are using the Test URL.</strong> The Test URL (<code>/webhook-test/</code>) does not expose <code>rawBody</code> as a string in all n8n versions. It works correctly on the Production URL (<code>/webhook/</code>) after you activate the workflow. Activate the workflow, switch to the Production URL in your provider’s dashboard, and re-test.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Cause 2: Raw Body toggle not enabled.</strong> In the Webhook node, open <strong>Options</strong> (the “Add Option” dropdown) → enable <strong>Raw Body</strong>. Without this, n8n parses the body and the original bytes are discarded.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Cause 3: Self-hosted Docker – payload exceeds default limit.</strong> n8n’s default maximum payload is 16 MB. Larger payloads are silently dropped. Set the environment variable in your Docker config:</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;"># docker-compose.yml
environment:
- N8N_PAYLOAD_SIZE_MAX=64 # value in MB
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;"><strong>Cause 4: Content-Type is not application/json.</strong> Some providers send <code>application/x-www-form-urlencoded</code> (Slack slash commands, for example). n8n parses these differently. When <code>rawBody</code> is needed for a form-encoded payload, the raw body string will look like <code>token=abc&team_id=T123...</code> — use that string as-is for your HMAC calculation, not a JSON-serialized version.</p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Symptom</th>
<th style="border: 1px solid #ddd; padding: 12px;">Most Likely Cause</th>
<th style="border: 1px solid #ddd; padding: 12px;">Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;"><code>$json.rawBody</code> is <code>undefined</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Raw Body toggle off, or Test URL in use</td>
<td style="border: 1px solid #ddd; padding: 12px;">Enable Raw Body; use Production URL</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;"><code>rawBody</code> is a parsed object, not a string</td>
<td style="border: 1px solid #ddd; padding: 12px;">Raw Body toggle was on but Test URL re-parses it</td>
<td style="border: 1px solid #ddd; padding: 12px;">Activate workflow and use Production URL</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Signature correct locally, fails in n8n</td>
<td style="border: 1px solid #ddd; padding: 12px;">Body was modified by a proxy before reaching n8n</td>
<td style="border: 1px solid #ddd; padding: 12px;">Bypass proxy for webhook path; see Section 7</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Empty string for <code>rawBody</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Payload too large for n8n default limit</td>
<td style="border: 1px solid #ddd; padding: 12px;">Set <code>N8N_PAYLOAD_SIZE_MAX</code> env variable</td>
</tr>
</tbody>
</table>
<p><!-- ============================================================ NEW SECTION 4.2 — JWT Auth vs HMAC confusion ============================================================ --></p>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">4.2 JWT Auth vs HMAC: Why They Are Not Interchangeable?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">n8n’s Webhook node has a built-in <strong>JWT Auth</strong> 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.</p>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Auth Method</th>
<th style="border: 1px solid #ddd; padding: 12px;">What It Validates</th>
<th style="border: 1px solid #ddd; padding: 12px;">Use When</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">None</td>
<td style="border: 1px solid #ddd; padding: 12px;">Nothing — all requests pass</td>
<td style="border: 1px solid #ddd; padding: 12px;">You handle verification manually in a Code node (recommended for Stripe, GitHub, Slack, Shopify)</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Basic Auth</td>
<td style="border: 1px solid #ddd; padding: 12px;"><code>Authorization: Basic base64(user:pass)</code> header</td>
<td style="border: 1px solid #ddd; padding: 12px;">Internal tools, legacy integrations — not for payment providers</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Header Auth</td>
<td style="border: 1px solid #ddd; padding: 12px;">A static value in a named header</td>
<td style="border: 1px solid #ddd; padding: 12px;">Internal APIs with a shared API key — not cryptographic</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">JWT Auth</td>
<td style="border: 1px solid #ddd; padding: 12px;">A signed JWT token (HS256/HS512)</td>
<td style="border: 1px solid #ddd; padding: 12px;">Services that send a full JWT — not GitHub/Stripe/Slack/Shopify</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">n8n Signature</td>
<td style="border: 1px solid #ddd; padding: 12px;">HMAC-SHA256 in <code>x-n8n-signature</code></td>
<td style="border: 1px solid #ddd; padding: 12px;">Custom senders you control — not third-party providers</td>
</tr>
</tbody>
</table>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>Rule of thumb:</strong> Set Webhook Authentication to <strong>None</strong> 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.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">4.3 Advanced Troubleshooting Checklist</h3>
<p><strong>If you encounter any </strong><a href="https://flowgenius.in/n8n-http-node-invalid-json-response-error">n8n http node invalid json response error </a><strong>resolve them before continuing.</strong></p>
<ul style="margin-bottom: 2em; line-height: 1.9;">
<li>[ ] <strong>View raw request</strong> – Click “Show Full Request” in the execution log; confirm header name/value.</li>
<li>[ ] <strong>Confirm environment variable</strong> – Use a Set node with <code>{{$env.SECRET_WEBHOOK}}</code> and inspect (mask in logs).</li>
<li>[ ] <strong>Check proxy configuration</strong> – For Nginx: <code>proxy_set_header X-n8n-signature $http_x_n8n_signature;</code></li>
<li>[ ] <strong>Validate encoding</strong> – Ensure payload is UTF‑8; re‑encode if necessary.</li>
<li>[ ] <strong>Guard against replay attacks</strong> – If the provider includes a timestamp (e.g., Stripe), add a Code node to reject signatures older than 5 minutes.</li>
<li>[ ] <strong>Log only non‑secret data</strong> – Never log the secret or full signature in production logs.</li>
<li>[ ] <strong>Use timingSafeEqual</strong> – Replace any <code>===</code> signature comparison with <code>crypto.timingSafeEqual()</code>.</li>
<li>[ ] <strong>Test URL vs Production URL</strong> – Confirm you are using the Production URL when testing with real provider webhooks.</li>
<li>[ ] <strong>Digest encoding</strong> – Verify you are using <code>.digest('hex')</code> or <code>.digest('base64')</code> to match the provider format (Shopify requires base64).</li>
</ul>
<p><!-- ============================================================ NEW SECTION 6 — Replay Attack Protection (full code) ============================================================ --></p>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">5. Production‑Grade EEFA Notes</h2>
<table style="border-collapse: collapse; width: 100%; margin-bottom: 2em;">
<thead>
<tr>
<th style="border: 1px solid #ddd; padding: 12px;">Concern</th>
<th style="border: 1px solid #ddd; padding: 12px;">Recommended Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Secret leakage</td>
<td style="border: 1px solid #ddd; padding: 12px;">Store in <strong>n8n Environment Variables</strong> or an external secret manager (AWS Secrets Manager, HashiCorp Vault).</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Header stripping by CDN</td>
<td style="border: 1px solid #ddd; padding: 12px;">Whitelist <code>x-n8n-signature</code> (Cloudflare → Transform Rules → Header Modification). See Section 7 for full Nginx and Cloudflare configs.</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Replay attacks</td>
<td style="border: 1px solid #ddd; padding: 12px;">Add a timestamp check and reject signatures older than a configurable window. See Section 6 for full implementation.</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Error handling</td>
<td style="border: 1px solid #ddd; padding: 12px;">Use an <strong>Error Workflow</strong> to capture signature failures and send alerts (e.g., Slack webhook).</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Audit trail</td>
<td style="border: 1px solid #ddd; padding: 12px;">Write a lightweight entry to PostgreSQL or MongoDB with <code>requestId</code>, <code>status</code>, and <code>timestamp</code> (exclude the signature value).</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Timing attacks</td>
<td style="border: 1px solid #ddd; padding: 12px;">Always use <code>crypto.timingSafeEqual()</code> for signature comparison — never <code>===</code>.</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 12px;">Secret rotation</td>
<td style="border: 1px solid #ddd; padding: 12px;">Update the env variable and re-register the webhook endpoint with the provider. Test immediately after rotation with a manual curl.</td>
</tr>
</tbody>
</table>
<hr style="margin: 55px 0;" />
<h2 style="margin-bottom: 45px; line-height: 1.3;">6. Replay Attack Protection: Full Implementation</h2>
<p style="margin-bottom: 2em; line-height: 1.9;">A valid signature proves the payload came from the right sender it does not prove the payload is <em>new</em>. 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.</p>
<p style="margin-bottom: 2em; line-height: 1.9;">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:</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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
});
</pre>
<p style="margin-bottom: 2em; line-height: 1.9;">On the n8n receiver side, add this block before your HMAC comparison:</p>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">// 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;
</pre>
<hr style="margin: 55px 0;" />
<p><!-- ============================================================ NEW SECTION 7 — Nginx / Cloudflare header passthrough ============================================================ --></p>
<h2 style="margin-bottom: 45px; line-height: 1.3;">7. Proxy and CDN Configuration – Stop Headers Being Stripped</h2>
<p style="margin-bottom: 2em; line-height: 1.9;">If your n8n instance sits behind Nginx, Traefik, or Cloudflare, custom headers like <code>x-n8n-signature</code>, <code>stripe-signature</code>, and <code>x-hub-signature-256</code> 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.</p>
<h3 style="margin-bottom: 25px; line-height: 1.3;">7.1 Nginx: Pass All Signature Headers Through</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;">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;
}
}
</pre>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">7.2 Cloudflare – Preserve Custom Headers</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Cloudflare does not strip standard headers by default, but certain firewall rules or WAF settings can block requests that contain unusual headers like <code>stripe-signature</code>. If Stripe webhooks pass from Cloudflare’s test tool but fail in production, check these settings:</p>
<ol style="margin-bottom: 2em; line-height: 1.9;">
<li><strong>WAF Custom Rules</strong> → confirm no rule blocks POST requests with <code>stripe-signature</code> or <code>x-hub-signature-256</code> headers.</li>
<li><strong>Transform Rules → Request Header Modification</strong> → add a rule to <em>set</em> a passthrough for any header you need preserved.</li>
<li><strong>Bot Fight Mode</strong> → 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.</li>
<li><strong>SSL/TLS → Full (Strict)</strong> → 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.</li>
</ol>
<blockquote style="margin: 2em 0; padding-left: 1em; border-left: 4px solid #ddd; font-style: italic;">
<p style="margin-bottom: 0; line-height: 1.9;"><strong>Quick debug:</strong> 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.</p>
</blockquote>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 25px; line-height: 1.3;">7.3 Docker Compose – Expose Webhook Port Directly (Bypass Proxy for Debugging)</h3>
<pre style="background: #fafafa; padding: 20px; border: 1px solid #e0e0e0; overflow: auto;"># 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
</pre>
<hr style="margin: 55px 0;" />
<p><!-- ============================================================ NEW FAQ SECTION — for featured snippets ============================================================ --></p>
<h2 style="margin-bottom: 45px; line-height: 1.3;">8. Frequently Asked Questions</h2>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. Why does n8n webhook signature verification keep failing even though the secret is correct?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">The most common reason is that <code>rawBody</code> is not available when you compute the HMAC. Either the <strong>Raw Body</strong> 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 <code>rawBody</code> 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 <code>.digest('hex')</code> for Shopify, which expects <code>.digest('base64')</code>.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. How do I verify a Stripe webhook signature in n8n?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Enable <strong>Raw Body</strong> on your Webhook node. In a Code node, parse the <code>stripe-signature</code> header to extract the timestamp (<code>t=</code>) and signature (<code>v1=</code>). Concatenate the timestamp, a dot, and the raw body string. Hash that concatenated string with HMAC-SHA256 using your Stripe signing secret (<code>whsec_...</code>). Compare the result to the <code>v1</code> value using <code>crypto.timingSafeEqual()</code>. 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.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. What is the difference between n8n’s built-in JWT Auth and HMAC signature verification?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">n8n’s JWT Auth validates a full JSON Web Token sent in the <code>Authorization: Bearer</code> 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 <strong>None</strong> and implement HMAC verification manually in a Code node.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. Why is $json.rawBody undefined in my n8n webhook?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;"><code>$json.rawBody</code> is only available when the <strong>Raw Body</strong> option is enabled in the Webhook node’s Options menu, AND you are using the <strong>Production URL</strong> (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.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. How do I verify a GitHub webhook signature in n8n?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Enable Raw Body on the Webhook node. In a Code node, read the <code>x-hub-signature-256</code> header from <code>$json.headers</code>. Compute <code>'sha256=' + HMAC_SHA256(secret, rawBody)</code>. Compare the result to the header value using <code>crypto.timingSafeEqual()</code>. The <code>sha256=</code> prefix must be included in your computed value — GitHub always sends it prefixed. The full code is in Section 3.4.2.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. Can I use the n8n Crypto node instead of a Code node for HMAC verification?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">Yes, for providers with a simple <code>sha256=hex</code> format like SeaTable or standard HMAC senders, the Crypto node works without code. Set Action to HMAC, Type to SHA256, value to <code>{{$json.rawBody}}</code>, 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.</p>
<h3 style="margin-bottom: 20px; line-height: 1.3;">Q. Does Cloudflare break n8n webhook signature verification?</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">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.</p>
<hr style="margin: 55px 0;" />
<h3 style="margin-bottom: 45px; line-height: 1.3;">Conclusion</h3>
<p style="margin-bottom: 2em; line-height: 1.9;">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 <code>.digest('hex')</code> for a provider that expects base64 (Shopify).</p>
<p style="margin-bottom: 2em; line-height: 1.9;">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.</p>
<p style="margin-bottom: 2em; line-height: 1.9;"><em>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.</em></p>

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
- Enable the “Signature” option on the Webhook node and store the secret in an environment variable.
- Send the
x-n8n-signature header – its value must be the HMAC‑SHA256 of the raw request body using the same secret.
- Confirm no proxy or CDN strips the header – view the raw request in the execution log.
- If the provider uses a different header name, copy it to
x-n8n‑signature with a Set node before validation.
- 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
- Open the Webhook node → Security → enable Signature.
- Set Secret to an environment variable:
{{$env.SECRET_WEBHOOK}}.
- Enable Raw Body in the Webhook node options — required for any HMAC verification.
- (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.
- Add a Webhook trigger node. Enable Raw Body in its settings.
- Add a Crypto node immediately after. Set:
Action: HMAC
Type: SHA256
Value: {{$json.rawBody}}
Secret: {{$env.SECRET_WEBHOOK}}
Encoding: hex
- Add an IF node. Compare the Crypto node output to the incoming header (stripping any
sha256= prefix first using a Set node).
- 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:
- WAF Custom Rules → confirm no rule blocks POST requests with
stripe-signature or x-hub-signature-256 headers.
- Transform Rules → Request Header Modification → add a rule to set a passthrough for any header you need preserved.
- 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.
- 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.