2026-06-08
n8n Webhook Security: HMAC Signatures, IP Filtering, and Rate Limiting
Secure your n8n webhooks for production. HMAC signature verification, IP whitelisting, rate limiting, webhook secret rotation, and replay attack prevention — complete security guide.
n8n Webhook Security: HMAC Signatures, IP Filtering, and Rate Limiting
A webhook endpoint is a public door into your automation infrastructure. Without proper security, anyone who discovers your webhook URL can trigger your workflows, spam your systems, and potentially exfiltrate data.
n8n webhooks are powerful — and they need to be secured. This guide covers production-grade webhook security patterns you can implement today.
The Webhook Security Threat Model
Before we dive into solutions, understand what you're defending against:
| Threat | Impact | Likelihood | |--------|--------|------------| | URL discovery/guessing | Anyone can trigger your workflows | High | | Replay attacks | Same payload sent multiple times | Medium | | Payload tampering | Modified data injected into your system | Medium | | DDoS / rate abuse | Workflow execution exhaustion, cost | Medium | | Man-in-the-middle | Payload interception and modification | Low (HTTPS) |
Layer 1: Webhook URL Hardening
Don't Use Guessable URLs
n8n generates random webhook URLs by default (e.g., /webhook/abc123def456). That's a good start. But for production:
# ❌ Weak
/webhook/new-order
# ✅ Strong
/webhook/8a7f3e9c2b1d4f6a8c0e2b4d6f8a0c2e
# ✅ Stronger: Path + secret in header
/webhook/8a7f3e9c2b1d with X-Webhook-Secret header verification
Production Pattern: Two-Factor Webhook Auth
[Webhook] → [Validate: URL path matches?]
├── No → [Respond 404 (don't reveal it exists)]
└── Yes → [Validate: X-Webhook-Secret header matches?]
├── No → [Respond 401]
└── Yes → [Process webhook]
Layer 2: HMAC Signature Verification
HMAC (Hash-based Message Authentication Code) is how major platforms (Stripe, GitHub, Shopify) secure their webhooks. The sender creates a hash of the payload using a shared secret, and you verify it.
How HMAC Webhooks Work
Sender (Stripe/GitHub/etc.):
1. Creates JSON payload
2. Computes: HMAC-SHA256(shared_secret, payload_string)
3. Sends payload + signature in header: X-Signature: sha256=abc123...
Receiver (Your n8n workflow):
1. Receives payload + signature
2. Computes: HMAC-SHA256(your_copy_of_secret, received_payload)
3. Compares your computed signature with the received signature
4. If they match → payload is authentic and unmodified
5. If they don't match → reject (401)
Implementing HMAC Verification in n8n
Workflow setup:
1. [Webhook node] — receives POST
└── Response Mode: "When Last Node Finishes"
2. [Code node] — HMAC verification (JavaScript):
// Get the signature from headers
const receivedSignature = $input.first().json.headers['x-signature'];
// Your shared secret (store in n8n credentials!)
const secret = 'your-shared-secret-here';
// Compute the expected signature
const crypto = require('crypto');
const payload = JSON.stringify($input.first().json.body);
const expectedSignature = 'sha256=' +
crypto.createHmac('sha256', secret).update(payload).digest('hex');
// Compare using timing-safe comparison
const crypto = require('crypto');
const sigBuffer = Buffer.from(receivedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (sigBuffer.length !== expectedBuffer.length) {
throw new Error('Invalid signature length');
}
if (!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
throw new Error('HMAC signature verification failed');
}
// Signature valid — continue processing
return { verified: true, payload: $input.first().json.body };
3. [IF node] — checked = true?
├── Yes → [Process the webhook]
└── No → [Webhook Response: 401 Unauthorized]
⚠️ Critical: Use Timing-Safe Comparison
Never use === or == to compare HMAC signatures. Attackers can use timing attacks to discover the secret byte by byte. Always use crypto.timingSafeEqual().
Layer 3: Replay Attack Prevention
A replay attack is when an attacker captures a valid webhook request and re-sends it — possibly multiple times. Without protection, you'd process the same order twice or send duplicate notifications.
Solution: Nonce + Timestamp Validation
[Webhook] → [Extract: X-Request-Timestamp, X-Request-Nonce]
→ [Validate: Timestamp within 5 minutes of current time?]
├── No → [Reject: 401 "Request expired"]
└── Yes → [Check: Nonce seen before? (cache lookup)]
├── Yes → [Reject: 401 "Replay detected"]
└── No → [Store nonce in cache (5-min TTL)] → [Process]
n8n Implementation:
Use a Redis cache or a database table:
CREATE TABLE webhook_nonces (
nonce VARCHAR(64) PRIMARY KEY,
created_at TIMESTAMP DEFAULT NOW()
);
-- Cleanup job: delete nonces older than 10 minutes
DELETE FROM webhook_nonces WHERE created_at < NOW() - INTERVAL '10 minutes';
JavaScript in Code node:
const nonce = $input.first().json.headers['x-request-nonce'];
const timestamp = parseInt($input.first().json.headers['x-request-timestamp']);
// Check timestamp is within 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Request timestamp expired');
}
// Check nonce hasn't been used (query your DB/cache)
// If nonce exists in DB → reject (replay attack)
// If nonce is new → store it and continue
Layer 4: IP Whitelisting
If you know exactly which services will send webhooks (Stripe, GitHub, your own services), restrict access by IP.
Option A: n8n-Level IP Filtering (Code Node)
[Webhook] → [Code node]:
// Allowed IPs (Stripe webhook IPs as example)
const allowedIPs = [
'3.18.12.63', '3.130.192.231', '13.235.122.149',
'13.235.133.245', '18.158.30.35', '18.211.135.69'
];
const clientIP = $input.first().json.headers['x-forwarded-for'] ||
$input.first().json.headers['x-real-ip'];
if (!allowedIPs.includes(clientIP)) {
throw new Error(`IP ${clientIP} not in whitelist`);
}
return { authorized: true };
Option B: Reverse Proxy IP Filtering (Recommended)
If n8n sits behind Nginx or Caddy, filter at the proxy level:
# Nginx: Only allow Stripe IPs for /webhook/stripe
location /webhook/stripe {
allow 3.18.12.63;
allow 3.130.192.231;
# ... other Stripe IPs
deny all;
proxy_pass http://localhost:5678;
}
Option C: Cloudflare WAF Rules
If you use Cloudflare, create a WAF rule:
- Field: IP Source Address
- Operator: is not in list
- Value: [your whitelisted IPs]
- Action: Block
Layer 5: Rate Limiting
Rate limiting prevents abuse, whether intentional (DDoS) or accidental (misconfigured sender floods you).
Simple Rate Limiter in n8n
// In-memory rate limiter (resets on n8n restart — use Redis for persistence)
const rateLimitMap = new Map();
function checkRateLimit(sourceIP, maxRequests = 60, windowMs = 60000) {
const now = Date.now();
const windowStart = now - windowMs;
if (!rateLimitMap.has(sourceIP)) {
rateLimitMap.set(sourceIP, []);
}
const requests = rateLimitMap.get(sourceIP);
// Remove old entries
const recentRequests = requests.filter(ts => ts > windowStart);
if (recentRequests.length >= maxRequests) {
throw new Error(`Rate limit exceeded for ${sourceIP}`);
}
recentRequests.push(now);
rateLimitMap.set(sourceIP, recentRequests);
return true;
}
// Usage in your webhook workflow
const clientIP = $input.first().json.headers['x-forwarded-for'];
checkRateLimit(clientIP, 30, 60000); // 30 requests per minute
Layer 6: Payload Validation
Always validate the webhook payload before processing. Never trust incoming data.
Schema Validation Pattern
[Webhook] → [Code: Validate payload schema]
├── Valid → [Process]
└── Invalid → [Webhook Response: 400 "Invalid payload: missing 'email' field"]
// Simple schema validation
function validatePayload(payload, requiredFields) {
const missing = requiredFields.filter(field => !(field in payload));
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(', ')}`);
}
// Type checking
if (typeof payload.amount !== 'number') {
throw new Error('Field "amount" must be a number');
}
return true;
}
validatePayload($input.first().json.body, ['email', 'event', 'timestamp']);
Complete Webhook Security Workflow Template
Here's a production-ready webhook security workflow for n8n:
[Webhook (POST)]
│
├── [Set Response: 500 (default)]
│
├── [Code: Layer 1 - IP Whitelist Check]
│ └── Reject if IP not allowed → [Response: 401]
│
├── [Code: Layer 2 - HMAC Signature Verification]
│ └── Reject if signature invalid → [Response: 401]
│
├── [Code: Layer 3 - Rate Limiting]
│ └── Reject if rate exceeded → [Response: 429 "Too Many Requests"]
│
├── [Code: Layer 4 - Replay Attack Check (Nonce)]
│ └── Reject if nonce reused → [Response: 401 "Replay Detected"]
│
├── [Code: Layer 5 - Payload Schema Validation]
│ └── Reject if invalid → [Response: 400 "Bad Request"]
│
├── [Process the webhook payload]
│
└── [Response: 200 "OK"]
Webhook Secret Rotation
Rotate webhook secrets periodically:
- Dual-secret approach: Maintain two active secrets during rotation
- Overlap period: Accept both old and new secrets for 24 hours
- Atomic cutover: After 24 hours, retire the old secret
- Alert on old secret usage: If old secret is used after cutover, alert — something is misconfigured
const secrets = {
current: 'secret-v2-abc123',
previous: 'secret-v1-xyz789', // Still accepted during rotation
};
function verifySignature(signature, payload) {
const expectedCurrent = computeHmac(secrets.current, payload);
const expectedPrevious = computeHmac(secrets.previous, payload);
const valid = timingSafeEqual(signature, expectedCurrent) ||
timingSafeEqual(signature, expectedPrevious);
if (!timingSafeEqual(signature, expectedCurrent) &&
timingSafeEqual(signature, expectedPrevious)) {
// Log: webhook used old secret — sender hasn't rotated yet
console.warn('Webhook received with previous secret — sender needs rotation');
}
return valid;
}
Monitoring and Alerting
Webhook security isn't "set and forget." Monitor:
| Metric | Alert Threshold | Action | |--------|----------------|--------| | 401 responses | > 5 in 10 minutes | Possible attack — investigate | | 429 rate limits hit | > 10 in 5 minutes | DDoS or misconfigured sender | | Old secret usage | Any after rotation window | Sender hasn't rotated — follow up | | Webhook response time | > 2 seconds average | Performance issue — optimize | | Delivery failure rate | > 5% | Sender may have issues |
Platform-Specific Security Settings
Stripe Webhooks
- Enable in Stripe Dashboard → Developers → Webhooks
- Copy the "Signing secret" — this is your HMAC key
- Stripe sends signatures as
stripe-signatureheader - Use Stripe's official library for signature verification
GitHub Webhooks
- Secret can be up to 100 characters
- GitHub sends
X-Hub-Signature-256: sha256=...header - Validate the signature before processing any event
Shopify Webhooks
- HMAC-SHA256 of the entire request body
- Signature in
X-Shopify-Hmac-SHA256header - Shopify also provides API-based webhook registration
Quick Security Checklist
Before exposing any n8n webhook to production:
- [ ] URL is not guessable (long random string, not descriptive path)
- [ ] HMAC signature verification is enabled
- [ ] Timing-safe comparison is used (not === or ==)
- [ ] IP whitelisting is configured (if sender IPs are known)
- [ ] Rate limiting is implemented
- [ ] Payload validation checks all required fields
- [ ] Replay attack prevention (nonce + timestamp) is active
- [ ] Webhook secret rotation plan exists
- [ ] Monitoring and alerting for 401/429 spikes
- [ ] HTTPS is enforced (TLS 1.2+)
- [ ] Error responses don't leak information
The Bottom Line
An unsecured n8n webhook is an open door to your automation infrastructure. Spend 30 minutes implementing HMAC + rate limiting + IP filtering. It's not paranoia — it's engineering.
Ready to automate?
Browse 25+ production-ready n8n templates. Import, configure, and run — all in under 10 minutes.
Browse Templates