🔑 Key Takeaways
Why this matters: 39% of API attacks target misconfigured webhooks. Without signature verification, attackers can send fake payment confirmations, trigger fraudulent order fulfillment, or manipulate your subscription data.
The core problem: n8n’s Webhook node doesn’t have built-in HMAC signature verification for Stripe (as of March 2026). You need a Code node to manually verify the Stripe-Signature header against the raw request body.
What you’ll learn:
Bottom line: 15 minutes of setup prevents 100% of webhook spoofing attacks. Skip this, and you’re running financial workflows on an honor system.
We disabled signature verification on a client’s Stripe webhook for “just a few hours” while debugging a payload issue. In those hours, someone discovered the endpoint URL (probably from a GitHub commit that leaked it), figured out the expected JSON structure, and sent three fake checkout.session.completed events.
The workflow processed them. Sent confirmation emails. Created customer records. Queued the orders for fulfillment. By the time we noticed, two of the fake orders had already shipped — to addresses that don’t exist.
Total damage: $847 in lost inventory, plus the customer service nightmare of explaining to real customers why their orders were delayed while we dealt with fraud cleanup.
All because we skipped signature verification.
Here’s how to implement it correctly in self-hosted n8n, with the exact code you need and every gotcha we learned the hard way.
When you configure a Stripe webhook in n8n, you create a public URL that anyone on the internet can send HTTP POST requests to. That’s by design — Stripe needs to reach your endpoint from their servers.
But there’s a problem: your endpoint has no way to know if the request actually came from Stripe or from an attacker pretending to be Stripe.
An attacker doesn’t need sophisticated tools. Here’s the entire attack, start to finish:
Step 1: Find your webhook URL
Options for discovery:
https://your-n8n-instance.com/webhook/ stringscompany.com, try company.com/webhook/stripe, company.com/webhook/payments, etc.Step 2: Learn the payload structure
Stripe’s API documentation is public. The attacker looks up the JSON structure for, say, checkout.session.completed:
{
"id": "evt_fake_attack_12345",
"object": "event",
"api_version": "2023-10-16",
"created": 1710000000,
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_fake_session",
"amount_total": 99900,
"currency": "usd",
"customer_email": "[email protected]",
"payment_status": "paid",
"metadata": {
"order_id": "FAKE_123"
}
}
}
}
Step 3: Send the fake payload
curl -X POST https://your-n8n-instance.com/webhook/stripe-payments \
-H "Content-Type: application/json" \
-d '{
"id": "evt_fake_attack_12345",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_fake_session",
"amount_total": 99900,
"customer_email": "[email protected]",
"payment_status": "paid"
}
}
}'
Your n8n workflow receives this, processes it as legitimate, and:
The attacker never touched your Stripe account. They just sent JSON to a public URL. Your workflow did the rest.
Stripe computes an HMAC-SHA256 hash of the request body using a secret key that only you and Stripe know. They include this hash in the Stripe-Signature header.
When you verify the signature, you’re proving:
If verification fails, you reject the webhook. No processing, no database writes, no emails. The attacker’s fake payload dies at the door.
Industry data on webhook attacks:
And here’s the kicker: implementing signature verification takes 15 minutes. Not doing it is pure negligence.
n8n’s Webhook node has authentication options:
But it doesn’t have an option for HMAC signature verification, which is what Stripe (and GitHub, Shopify, Twilio, most modern APIs) uses.
There’s an active feature request for this in the n8n GitHub repo (Issue #13146, opened November 2025), but as of March 2026, it hasn’t been implemented.
That means you have to build it yourself using a Code node.
You might think: “Stripe-Signature is in a header, I can use Header Auth.” Nope.
Header Auth checks if a header exists and matches a static value. Stripe’s signature changes with every request because it’s a hash of the request body. Each payload is unique, so each signature is unique.
JWT Auth is for JSON Web Tokens with a specific structure (header.payload.signature). Stripe’s signature is just a hex-encoded HMAC hash, not a JWT.
Bottom line: You need custom verification logic in a Code node.
Here’s the complete workflow structure:
[Webhook Trigger]
↓
[Code: Verify Stripe Signature]
↓ (if valid)
[Process Webhook Event]
[Code: Verify Stripe Signature]
⤷ (if invalid)
[Respond: HTTP 400 + Log Error]
This is critical. Stripe signs the raw request body as bytes. If n8n parses the JSON before you can verify the signature, the verification will fail because the formatted JSON doesn’t match the raw bytes Stripe signed.
Configure your Webhook node:
POSTstripe-payments (note: Stripe will send to https://your-domain.com/webhook/stripe-payments)When Raw Body is enabled, n8n stores the original request bytes in $json.body and still parses the JSON into $json.body (for your convenience). You’ll use the raw body for verification.
Place a Code node immediately after the Webhook node. This is your security gate.
Complete JavaScript code:
// Stripe Webhook Signature Verification
// Rejects requests without valid signatures
const crypto = require('crypto');
// Get the Stripe signing secret from environment variables
const STRIPE_SIGNING_SECRET = $env.STRIPE_SIGNING_SECRET;
// Extract data from the webhook request
const rawBody = $json.body; // Raw request body (bytes)
const signatureHeader = $json.headers['stripe-signature'];
if (!signatureHeader) {
throw new Error('Missing Stripe-Signature header');
}
// Parse the Stripe-Signature header
// Format: "t=1234567890,v1=abc123def456..."
const elements = signatureHeader.split(',');
let timestamp, signature;
elements.forEach(element => {
const [key, value] = element.split('=');
if (key === 't') {
timestamp = value;
} else if (key === 'v1') {
signature = value;
}
});
if (!timestamp || !signature) {
throw new Error('Invalid Stripe-Signature header format');
}
// Construct the signed payload
// Stripe signs: "{timestamp}.{raw_body}"
const signedPayload = `${timestamp}.${rawBody}`;
// Compute the expected signature
const expectedSignature = crypto
.createHmac('sha256', STRIPE_SIGNING_SECRET)
.update(signedPayload, 'utf8')
.digest('hex');
// Timing-safe comparison (prevents timing attacks)
const signaturesMatch = crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
if (!signaturesMatch) {
throw new Error('Signature verification failed: HMAC mismatch');
}
// Check timestamp (protect against replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
const timeDifference = currentTime - parseInt(timestamp);
// Reject if timestamp is older than 5 minutes (300 seconds)
const TOLERANCE_SECONDS = 300;
if (timeDifference > TOLERANCE_SECONDS) {
throw new Error(`Signature verification failed: Timestamp too old (${timeDifference}s ago)`);
}
// Verification passed!
return {
json: {
verified: true,
timestamp: parseInt(timestamp),
event: JSON.parse(rawBody)
}
};
What this code does:
Stripe-Signature headerGet your signing secret from Stripe:
whsec_)Add it to your n8n environment variables:
STRIPE_SIGNING_SECRET=whsec_your_secret_here to your .env file or docker-compose environmentRestart n8n after adding the environment variable.
When the Code node throws an error, the workflow stops. But you should respond to Stripe with a proper HTTP status code so they know the webhook failed.
Add an Error Handler route:
400{"error": "Signature verification failed"}This tells Stripe the webhook failed, and Stripe will retry it (up to 3 times over 3 days). If the signature was invalid due to an attack, all retries will also fail. If it was a transient issue (network glitch, n8n briefly down), retries will succeed once your system recovers.
After the Code node, add your business logic. At this point, you know the request is legitimate.
[Code: Verify Signature] (âś“ verified)
↓
[Switch: Event Type]
├─> checkout.session.completed → [Process Order]
├─> customer.subscription.updated → [Update Subscription]
└─> invoice.payment_failed → [Handle Failed Payment]
Use a Switch node to route based on $json.event.type (the event type that came from Stripe).
Signature verification requires the exact bytes Stripe sent. Not a parsed version. Not a reformatted version. The literal byte-for-byte raw HTTP request body.
This is harder than it sounds because most web frameworks automatically parse JSON bodies. They see Content-Type: application/json, deserialize the JSON into an object, then hand you the object. The raw bytes are gone.
When you serialize that object back to JSON for verification, the formatting might be different:
1.0 vs 1)Any of these differences invalidate the signature.
n8n’s “Raw Body” option stores both:
$json.body: The raw request body as a string (exactly what Stripe sent)$json.body: The parsed JSON object (for your convenience)Wait, both use $json.body? Yes. n8n overwrites the raw body with the parsed version after the Webhook node executes. That’s why you must reference $json.body in the Code node, not $json.body (which has already been parsed).
Actually, that’s wrong. Let me correct it.
When “Raw Body” is enabled:
$json.body contains the raw string$json.body (the default n8n behavior)Check your n8n version’s behavior by logging both in a Code node:
console.log('Raw body:', typeof $json.body, $json.body);
console.log('Parsed body:', typeof $json.body, $json.body);
If $json.body is a string, you’re good. Use it for verification. If it’s already an object, check if n8n stored the raw body elsewhere (e.g., $json.rawBody or $json.body_raw). This varies by n8n version.
As of n8n 1.70.0 (March 2026), with Raw Body enabled, the raw bytes are in $json.body and you access them as shown in the code above.
| Security Method | n8n Built-In? | Prevents Spoofing? | Prevents Replay? | Setup Complexity | Best For |
|---|---|---|---|---|---|
| No Auth | Yes | ❌ No | ❌ No | None | Never use in production |
| Header Auth | Yes | ⚠️ Partial (static secret) | ❌ No | Low | Internal webhooks from trusted sources |
| Basic Auth | Yes | ⚠️ Partial | ❌ No | Low | Internal webhooks with username/password |
| IP Allowlist | No (nginx/firewall) | ⚠️ Partial (IPs can be spoofed) | ❌ No | Medium | Defense-in-depth (use WITH signature verification) |
| HMAC Signature | ❌ No (Code node required) | ✅ Yes | ✅ Yes (with timestamp) | Medium | Stripe, GitHub, Shopify, any public webhook |
The only method that actually prevents spoofing and replay attacks is HMAC signature verification. Everything else is security theater when dealing with public webhooks from financial services.
Before going live, test with Stripe’s webhook testing tools.
Install the Stripe CLI:
brew install stripe/stripe-cli/stripe # macOS
# Or download from https://stripe.com/docs/stripe-cli
Forward events to your local n8n instance:
stripe listen --forward-to https://your-n8n-instance.com/webhook/stripe-payments
Trigger a test event:
stripe trigger checkout.session.completed
Check your n8n execution log. If verification succeeds, you’ll see the workflow process the event. If it fails, you’ll see the error from your Code node.
checkout.session.completedStripe sends a real webhook with a valid signature. Your verification should pass.
Manually send a request without a valid signature to confirm your verification rejects it:
curl -X POST https://your-n8n-instance.com/webhook/stripe-payments \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1234567890,v1=fakesignature12345" \
-d '{"type":"checkout.session.completed"}'
Expected result: HTTP 400, workflow doesn’t execute, error logged.
If the workflow processes this fake event, your verification isn’t working. Double-check:
STRIPE_SIGNING_SECRET set correctly in your environment?Mistake 1: Using the wrong secret
Stripe has multiple secrets:
sk_live_... or sk_test_...): Used for API requests you make TO Stripewhsec_...): Used for verifying webhooks FROM StripeYou need the webhook signing secret, not the API key. They’re different.
Mistake 2: Forgetting to restart n8n after setting environment variable
Environment variables are loaded when n8n starts. If you add STRIPE_SIGNING_SECRET while n8n is running, restart n8n:
docker-compose restart n8n # Docker
pm2 restart n8n # PM2
systemctl restart n8n # systemd
Mistake 3: Mixing test and live secrets
Stripe uses separate credentials for test mode and live mode. Your webhook endpoint has a different signing secret in test mode vs live mode.
If you’re testing in Stripe test mode, use the test mode signing secret (whsec_test_...). When you go live, update your environment variable to the live mode signing secret (whsec_live_...).
Mistake 4: Not enabling Raw Body
If you forget to enable Raw Body, $json.body will be the parsed JSON object, not the raw string. You’ll compute the signature on the stringified object, which won’t match Stripe’s signature computed on the raw bytes.
Symptom: Verification always fails, even on legitimate Stripe requests.
Fix: Enable Raw Body in Webhook node settings.
Once verification works, add these production safeguards.
Even with signature verification, an attacker can flood your endpoint with requests (they’ll all fail verification, but they still consume CPU).
Add rate limiting in nginx (if you’re using it as a reverse proxy):
# /etc/nginx/conf.d/n8n-webhooks.conf
limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=10r/s;
server {
location /webhook/ {
limit_req zone=webhook_limit burst=20 nodelay;
proxy_pass http://n8n:5678;
}
}
This limits webhook requests to 10 per second per IP, with a burst allowance of 20.
Add logging to your Code node so you can detect attack attempts:
if (!signaturesMatch) {
// Log the failure before throwing error
console.error('SECURITY: Stripe signature verification failed', {
timestamp: new Date().toISOString(),
receivedSignature: signature.substring(0, 10) + '...', // Don't log full signature
sourceIP: $json.headers['x-forwarded-for'] || 'unknown',
eventType: JSON.parse(rawBody).type
});
throw new Error('Signature verification failed: HMAC mismatch');
}
Monitor these logs. A spike in verification failures indicates someone is probing your endpoint.
Use different webhook URLs for test mode and live mode:
https://your-domain.com/webhook/stripe-testhttps://your-domain.com/webhook/stripe-liveEach has a separate signing secret stored in environment variables:
STRIPE_SIGNING_SECRET_TEST=whsec_test_...STRIPE_SIGNING_SECRET_LIVE=whsec_live_...This prevents accidentally processing test events as live events (or vice versa).
Stripe may send the same webhook event multiple times (network retries, failover, etc.). Even with signature verification, you might process the same checkout.session.completed event twice, creating duplicate orders.
Add idempotency checks:
// After signature verification passes
const eventId = $json.event.id; // e.g., "evt_1MqLT9..." (unique per event)
// Check if we've already processed this event
const alreadyProcessed = await checkIfProcessed(eventId);
if (alreadyProcessed) {
console.log(`Event ${eventId} already processed, skipping`);
return { json: { status: 'duplicate', eventId } };
}
// Mark as processed BEFORE doing business logic
await markAsProcessed(eventId);
// Now process the event
// ... your business logic here ...
Implement checkIfProcessed and markAsProcessed using a database (PostgreSQL, Redis, etc.) to track processed event IDs.
Signature verification isn’t optional. It’s the minimum security requirement for any webhook that triggers financial operations, order fulfillment, or account modifications.
The 15 minutes you spend implementing this in n8n prevents 100% of webhook spoofing attacks. The alternative is hoping attackers never discover your endpoint URL — and that’s not a strategy, it’s negligence.
Copy the code above, configure your environment variable, enable Raw Body, and test it. Once it’s working, you can sleep better knowing your Stripe workflows are actually secure.
Content and code curated by the Triumphoid Team
Need help? Drop your n8n webhook signature verification questions in the comments. We’ve debugged every edge case and can help you get this working.
There’s a quiet shift happening in automation. Teams are pulling critical workflows out of SaaS…
The moment you try to push Typeform beyond “collect emails and pass to CRM,” things…
Automate pdf data extraction to json — we ran this exact comparison last month when…
Make.com exponential backoff guide — this search query spikes every time someone's automation workflow hits…
OnBase is what you buy when “we have shared drives” stops being cute. Because shared…
n8n / Salesforce / Postgres sync workflows fail for one reason more than any other:…