TL;DR
Publieke webhooks zijn een aanvalsvector. Veel providers (Stripe, GitHub, Shopify, ElevenLabs, …) sturen daarom een HMAC signature mee in een header. n8n valideert die signature niet standaard. Als jij geen verificatie bouwt, kan iemand requests faken en jouw workflows triggeren.
- Enable Raw Body in je Webhook node (providers signen de raw body, niet de parsed JSON).
- Bereken HMAC-SHA256 met jouw signing secret.
- Vergelijk timing-safe met
crypto.timingSafeEqual(). - Stop direct en return 403 bij mismatch.
- Voorkom replay (optioneel) met timestamps/tolerance.
Wil je eerst je algemene webhook basis opfrissen? Lees de Webhook Node tutorial en daarna de brede hardening in n8n security best practices.
Inhoudsopgave
- Waarom tokens/Basic Auth niet genoeg zijn
- Hoe HMAC signatures werken
- Het herbruikbare n8n patroon (verify → route → respond)
- Voorbeeld: GitHub (x-hub-signature-256)
- Voorbeeld: Stripe (Stripe-Signature)
- Replay protection (timestamp tolerance)
- Troubleshooting checklist
- Production checklist
Waarom tokens/Basic Auth niet genoeg zijn
Een API key of Basic Auth bewijst meestal alleen dat iemand “het geheim kent”. Maar je wil ook zekerheid dat:
- de request echt van de provider komt,
- de body onderweg niet is aangepast,
- en dat iemand geen oude request opnieuw kan afspelen (replay).
Daarom gebruiken providers signatures. Jij berekent dezelfde signature opnieuw, en vergelijkt die met de header. Match = OK. Geen match = meteen blokkeren.
Hoe HMAC signatures werken
Conceptueel:
expected = HMAC_SHA256(secret, rawBody)
received = headerSignature
if expected != received: deny
Belangrijk: vrijwel altijd wordt de raw request body gesigned. Dus als jij JSON eerst laat parsen en daarna opnieuw serialize’t, krijg je mismatch.
Het herbruikbare n8n patroon (verify → route → respond)
- Webhook node
- HTTP method: POST
- Response: werk met “Respond to Webhook” (of response mode) zodat je 403 kunt teruggeven
- Raw Body: aan (zodat je
$json.rawBodyhebt)
- Verify Signature (Code node): bereken HMAC + timing-safe compare → return
{ verified: true/false } - IF: verified == true → door naar je business logic
- Else: Respond to Webhook → status 403 → stop workflow
Generiek Code node template (HMAC SHA-256)
const crypto = require('crypto');
const secret = $env.WEBHOOK_SIGNING_SECRET;
const rawBody = $json.rawBody ?? '';
const signatureHeader = ($json.headers?.['x-signature'] ?? '').toString();
if (!secret) throw new Error('Missing WEBHOOK_SIGNING_SECRET');
if (!rawBody) throw new Error('Missing rawBody (enable Raw Body in Webhook node)');
if (!signatureHeader) return { json: { verified: false } };
// Providers gebruiken soms een prefix zoals sha256=
const receivedHex = signatureHeader.replace(/^sha256=/, '').trim();
const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(receivedHex, 'hex');
const expected = Buffer.from(expectedHex, 'hex');
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);
return { json: { verified: ok } };
Voorbeeld: GitHub (x-hub-signature-256)
GitHub zet de signature in x-hub-signature-256 met prefix sha256=. Je gebruikt dezelfde raw body als input.
const crypto = require('crypto');
const secret = $env.GITHUB_WEBHOOK_SECRET;
const rawBody = $json.rawBody ?? '';
const sig = ($json.headers?.['x-hub-signature-256'] ?? '').toString();
const receivedHex = sig.replace(/^sha256=/, '').trim();
const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(receivedHex, 'hex');
const expected = Buffer.from(expectedHex, 'hex');
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);
return { json: { verified: ok } };
Voorbeeld: Stripe (Stripe-Signature)
Stripe gebruikt een timestamp en signeert meestal timestamp + '.' + rawBody. In de header staan vaak t=... en v1=....
const crypto = require('crypto');
const secret = $env.STRIPE_SIGNING_SECRET;
const rawBody = $json.rawBody ?? '';
const sig = ($json.headers?.['stripe-signature'] ?? '').toString();
const parts = sig.split(',').map(s => s.trim());
const tPart = parts.find(p => p.startsWith('t='));
const v1Part = parts.find(p => p.startsWith('v1='));
if (!tPart || !v1Part) return { json: { verified: false } };
const timestamp = tPart.split('=')[1];
const receivedHex = v1Part.split('=')[1];
const signedPayload = `${timestamp}.${rawBody}`;
const expectedHex = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
const received = Buffer.from(receivedHex, 'hex');
const expected = Buffer.from(expectedHex, 'hex');
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);
return { json: { verified: ok, timestamp } };
Replay protection (timestamp tolerance)
Signature check voorkomt manipulatie, maar niet altijd replay. Bij Stripe kun je een simpele tolerance check doen:
const toleranceSeconds = 300; // 5 minuten
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
return { json: { verified: false, reason: 'timestamp outside tolerance' } };
}
Troubleshooting checklist
- Raw Body staat niet aan → je signeert niet dezelfde input.
- Header key mismatch → headers zijn vaak lowercased (
Stripe-Signature→stripe-signature). - Verkeerde secret → Stripe secrets zijn per endpoint.
- Encoding/whitespace → trim prefix, gebruik dezelfde encoding.
- Proxy/middleware die de body wijzigt → test direct op n8n.
Production checklist
- Raw Body enabled op signature endpoints
- Secrets via env vars/credentials (geen hardcoded secrets)
- Timing-safe compare (
crypto.timingSafeEqual()) - 403 bij mismatch (geen business logic uitvoeren)
- Replay protection waar mogelijk
- Logging zonder secrets/payload dumps
- Extra hardening: rate limiting, IP allowlisting, aparte webhook subdomain
Hulp nodig met een veilige n8n setup?
Als je workflows bedrijfskritisch zijn, wil je dit patroon consistent en goed getest uitrollen. Wil je dat wij je n8n omgeving veilig opzetten of verbeteren? Bekijk de n8n installatie service.