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:

  1. Dual-secret approach: Maintain two active secrets during rotation
  2. Overlap period: Accept both old and new secrets for 24 hours
  3. Atomic cutover: After 24 hours, retire the old secret
  4. 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-signature header
  • 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-SHA256 header
  • 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.

Browse our secure, production-tested n8n templates →`

Ready to automate?

Browse 25+ production-ready n8n templates. Import, configure, and run — all in under 10 minutes.

Browse Templates