Why Webhook Security Matters
By default, an n8n webhook URL is publicly accessible to anyone who knows the path. If you are triggering workflows that send emails, update databases, or call APIs on behalf of users, an unauthenticated webhook is a serious risk — anyone can send a spoofed request and trigger your workflow.
n8n provides built-in authentication options and enough flexibility in Code nodes to implement HMAC signature verification for any provider. This guide covers both.
n8n Built-in Authentication Options
When you create a Webhook node, open the Authentication dropdown. Three options are available:
| Option | How it works | Best for |
|---|---|---|
| None | No authentication — any request is accepted | Internal networks only |
| Basic Auth | HTTP Basic Auth — username and password in the Authorization header | Simple integrations where you control both sides |
| Header Auth | A custom header name and value that must match | API key style auth for third-party integrations |
To configure Header Auth: set Authentication to Header Auth, set Header Name to X-Api-Key (or any name you choose), and set Header Value to a long random secret. The sending service must include this header on every request.
Generate secure header values with: python -c "import secrets; print(secrets.token_hex(32))"HMAC Signature Validation for GitHub, Stripe, and Others
Many services (GitHub, Stripe, Shopify, Twilio) sign their webhook payloads using HMAC-SHA256 and send the signature in a header. n8n's built-in authentication cannot verify these — you need a Code node.
The Pattern
Add a Code node immediately after your Webhook node, before any other processing. If verification fails, throw an error to halt the workflow.
GitHub Webhooks
// Code node — runs immediately after Webhook
const crypto = require('crypto');
const secret = $env.GITHUB_WEBHOOK_SECRET; // set in n8n environment variables
const payload = $input.first().json.body; // raw request body as string
const signature = $input.first().headers['x-hub-signature-256'];
if (!signature) {
throw new Error('Missing x-hub-signature-256 header — request rejected');
}
// Compute expected signature
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'utf8');
const expBuffer = Buffer.from(expected, 'utf8');
if (sigBuffer.length !== expBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expBuffer)) {
throw new Error('Invalid GitHub webhook signature — request rejected');
}
// Signature valid — pass through
return $input.all();
Stripe Webhooks
// Stripe uses a timestamp + signature scheme to prevent replay attacks
const crypto = require('crypto');
const secret = $env.STRIPE_WEBHOOK_SECRET; // starts with whsec_
const payload = $input.first().json.body;
const sigHeader = $input.first().headers['stripe-signature'];
if (!sigHeader) throw new Error('Missing Stripe-Signature header');
// Parse the header: t=timestamp,v1=signature
const parts = {};
sigHeader.split(',').forEach(part => {
const [k, v] = part.split('=');
parts[k] = v;
});
const timestamp = parts['t'];
const v1sig = parts['v1'];
// Reject if timestamp is older than 5 minutes (replay attack protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Stripe webhook timestamp too old — possible replay attack');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(v1sig), Buffer.from(expected))) {
throw new Error('Invalid Stripe webhook signature');
}
return $input.all();
Never compare signatures with === or ==. Use crypto.timingSafeEqual() to prevent timing attacks where an attacker could guess the signature one byte at a time by measuring response time.Accessing the Raw Body in n8n
HMAC validation requires the raw, unmodified request body — exactly as received. n8n provides this if you configure the Webhook node correctly:
- In your Webhook node, set 'Binary Data' property if you need raw bytes.
- For string-based HMAC: the body is available at $input.first().json.body when 'Raw Body' is enabled in newer n8n versions.
- If you cannot access the raw body, use the Respond to Webhook node with a 200 immediately (to satisfy the provider's timeout), then process the payload in subsequent nodes.
IP Allowlisting
For providers that publish their outbound IP ranges (GitHub, Stripe, Shopify), you can add IP validation as a first line of defence. Add this at the start of your Code node:
// GitHub's webhook IP ranges (check https://api.github.com/meta for current list)
const ALLOWED_IPS = [
'192.30.252.0/22',
'185.199.108.0/22',
'140.82.112.0/20',
'143.55.64.0/20',
];
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/');
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
const ipInt = ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0);
const rangeInt = range.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0);
return (ipInt & mask) === (rangeInt & mask);
}
const clientIp = $input.first().headers['x-forwarded-for']?.split(',')[0]?.trim()
|| $input.first().headers['x-real-ip'];
const allowed = ALLOWED_IPS.some(cidr => ipInCidr(clientIp, cidr));
if (!allowed) {
throw new Error(`Blocked request from IP: ${clientIp}`);
}
IP allowlisting is defence in depth, not a replacement for signature verification. IP ranges can be spoofed and provider ranges change. Always combine both.Storing Secrets Safely
Never hardcode webhook secrets in Code nodes. Use n8n's environment variable system:
- In your n8n instance, go to Settings → Environment Variables (self-hosted: set in the .env file or Docker environment).
- Add your secrets: GITHUB_WEBHOOK_SECRET=your_secret_here
- Reference in Code nodes with $env.GITHUB_WEBHOOK_SECRET
- For n8n Cloud: use the Credentials system and reference via $credentials where possible.
Error Handling After Rejection
When a webhook fails signature validation, the workflow throws an error and stops. Configure an Error Workflow to log these failures — repeated failures from unknown IPs may indicate a scan or brute force attempt worth alerting on.