Webhook Forgery: Stripe, Twilio, SendGrid, and the Signature Verification Developers Always Get Wrong
Your payment processor sends you a webhook saying a customer paid. You mark their order fulfilled. Except nobody paid. An attacker forged the webhook. Webhook signature verification is the most commonly skipped, misimplemented, or silently-broken security control in modern web applications. The specific bugs we find on every audit and how to actually implement verification correctly.
Founder of Valtik Studios. Pentester. Based in Connecticut, serving US mid-market.
The forged-webhook breach I've seen three times this year
An e-commerce client called us in February. Somebody was marking high-value orders as paid without actually paying. Stripe had no record of the transactions. The orders were shipping. The fraud team was chasing chargebacks that didn't exist because there was no original charge.
Our first question. "How do you verify Stripe webhooks?" Silence on the line.
That's the pattern on every webhook forgery engagement I've run. Developers assume webhook endpoints are safe because they're not linked from the public site. Attackers find them by decompiling mobile apps or reading Stripe's docs and guessing the URL structure. Then they forge the payload and your application trusts it.
The attack that always works
Your application takes payments via Stripe. When a customer pays, Stripe sends a webhook to your endpoint (POST /webhooks/stripe) telling you the payment succeeded. Your application marks the order paid, notifies fulfillment, ships the product.
An attacker figures out (or guesses) your webhook URL. They craft an HTTP POST that looks exactly like a Stripe webhook payload. Same structure, same fields. They send it to your webhook endpoint. Your application accepts it as a legitimate payment notification. The order gets marked paid. The product ships. Stripe never saw any of this.
Webhook forgery. It's one of the most reliable attacks on modern web applications because webhook signature verification is the control that developers most commonly skip, misimplement, or break without realizing.
On penetration tests of B2B SaaS products, e-commerce platforms. And fintech applications, we find webhook forgery vulnerabilities at a rate of roughly 60-70% of engagements. The issue spans every major webhook provider. Stripe, Twilio, SendGrid, GitHub, Slack, Salesforce, HubSpot, Shopify, Discord, Stripe Connect, you name it.
This post walks through how webhook forgery attacks work, the specific signature verification mistakes we find. And the correct implementation pattern for the major webhook providers.
Why webhooks are uniquely vulnerable
Most web application security thinking centers on direct HTTP requests from users. Authentication, CSRF, session management. All designed for the "user clicks a button" flow.
Webhooks are different. They're HTTP POSTs from third-party services to your application, typically:
- Not authenticated by session (the caller isn't a logged-in user)
- Routed to dedicated endpoints that are often exempt from CSRF protection
- Expected to trigger significant state changes (mark orders paid, issue refunds, update user status)
- Discoverable via DNS enumeration or configuration disclosure
The authentication model for webhooks relies on signature verification: the webhook provider signs the payload with a shared secret. And your application verifies the signature before processing.
When signature verification is skipped, misimplemented, or bypassable, the webhook endpoint becomes a direct path to authenticated-level state changes via unauthenticated HTTP.
The specific bugs we find
Bug 1: No signature verification at all
The most common. Developer implements the webhook handler, gets it working, ships. Verification is "TODO: add later." Later never comes.
Typical vulnerable code:
// POST /webhooks/stripe
app.post('/webhooks/stripe', express.json(), async (req, res) => {
const event = req.body; // directly trust the payload
if (event.type === 'payment_intent.succeeded') {
const paymentId = event.data.object.id;
await markOrderPaid(paymentId); // state change on forged data
}
res.json({ received: true });
});
Anyone who can send HTTP POSTs can trigger markOrderPaid(). The attacker needs a payment ID that exists in your system (often discoverable from order confirmation URLs, email receipts, or social engineering).
Bug 2: Verifying signature but not verifying the secret is correct
Some developers implement signature verification but use a hardcoded secret that's wrong, expired, or shared between test and production.
const signature = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body,
signature,
'whsec_test_1234567890' // development secret in production code
);
Attackers who have ever seen the test secret (via GitHub commit, stack traces, error pages) can forge webhooks.
Bug 3: Timing-attack-vulnerable comparison
// BAD: timing-vulnerable
if (providedSignature === expectedSignature) {
//...
}
String comparison returns false as soon as characters don't match. An attacker can measure response time to determine how many characters match, byte-by-byte, until they've the full signature. This works against weak signature schemes. Modern HMAC signatures are typically too long for practical timing attacks but the defense costs nothing.
// GOOD: constant-time comparison
if (crypto.timingSafeEqual(
Buffer.from(providedSignature),
Buffer.from(expectedSignature)
)) {
//...
}
Bug 4: Verifying signature on JSON-parsed body instead of raw body
// BAD: verifying signature on parsed body
app.use(express.json()); // parses body to JS object
app.post('/webhooks/stripe', async (req, res) => {
// req.body is now an object, can't verify against raw signature
const serialized = JSON.stringify(req.body); // may differ from original
const valid = verifySignature(serialized, req.headers['stripe-signature']);
//...
});
JSON parsing is lossy. The re-serialized version may differ in key ordering, whitespace, or unicode escaping. Signatures are computed over raw bytes. Verifying against re-serialized bytes fails for legitimate webhooks and can be exploited for forgery in some cases.
Correct pattern:
// GOOD: verify raw body first
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body; // Buffer containing raw bytes
const event = stripe.webhooks.constructEvent(
rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
// Now parse the verified event
//...
}
);
Bug 5: Not verifying the timestamp
Webhook signatures typically include a timestamp. Without checking it, captured legitimate webhooks can be replayed indefinitely.
Attack scenario:
- Attacker captures one legitimate webhook (via network observation, server logs, etc.)
- Replays it weeks or months later
- Application processes the old webhook as if it's new
For payment webhooks, this means double-charging. For event webhooks, this means duplicate state changes.
Good implementation:
const now = Math.floor(Date.now() / 1000);
const eventTimestamp = parseInt(signatureHeaderTimestamp);
if (Math.abs(now - eventTimestamp) > 300) { // 5 minute tolerance
throw new Error('Webhook timestamp too old');
}
Bug 6: Verifying signature but not the specific webhook source
Some applications receive webhooks from multiple providers or multiple accounts with the same provider. If they use a single shared secret for all, an attacker who gets one webhook secret can forge webhooks apparently from any source.
Correct pattern: separate secrets per source. Stripe webhook signed with Stripe secret. Twilio webhook signed with Twilio secret. Separate signature algorithms per provider.
Bug 7: Accepting webhooks on both signed and unsigned endpoints
Development environments often have unsigned webhook endpoints for testing. These endpoints sometimes persist into production with verification disabled or weak.
# BAD: dev endpoint still exists in production
@app.post('/webhooks/stripe/dev') # no signature check
def stripe_dev_webhook():
event = request.json
process_event(event)
@app.post('/webhooks/stripe') # proper signature check
def stripe_prod_webhook():
event = stripe.Webhook.construct_event(...)
process_event(event)
Attackers who find /webhooks/stripe/dev can bypass verification entirely.
Bug 8: Idempotency failures
Legitimate webhooks sometimes retry (network issues, timeouts). Applications need to handle retry gracefully without duplicating work. But without idempotency checks, a forged webhook can be "replayed" to cause intentional duplication:
# BAD: not idempotent
def mark_order_paid(order_id):
order = Order.get(order_id)
order.status = 'PAID'
send_confirmation_email(order.customer_email) # duplicate emails
ship_product(order) # duplicate shipment
order.save()
Better:
# GOOD: idempotent
def mark_order_paid(order_id, event_id):
order = Order.get(order_id)
if order.paid_event_id == event_id:
return # already processed this event
if order.status == 'PAID':
return # already paid
order.status = 'PAID'
order.paid_event_id = event_id
send_confirmation_email_once(order)
ship_product_once(order)
order.save()
Bug 9: Race conditions on concurrent webhook deliveries
Providers may deliver the same webhook multiple times within seconds. Without proper locking, concurrent processing can create duplicate state changes.
Defense: database-level unique constraints on event IDs. Application-level locking. Idempotent logic throughout.
Bug 10: Trusting data from the webhook without revalidation
Webhooks carry data. Applications sometimes trust the data without revalidating against the source:
// BAD: trusts the amount in the webhook
if (event.type === 'payment_intent.succeeded') {
const amount = event.data.object.amount;
await creditUserAccount(userId, amount);
// attacker could forge a webhook with inflated amount
}
Correct:
// GOOD: re-fetch from the source
if (event.type === 'payment_intent.succeeded') {
const paymentId = event.data.object.id;
const payment = await stripe.paymentIntents.retrieve(paymentId);
// authenticated API call confirms amount
if (payment.status === 'succeeded') {
await creditUserAccount(userId, payment.amount);
}
}
Even with good signature verification, re-fetching from the source API prevents the class of attacks where a compromised signing secret gets used to forge webhooks.
Provider-specific correct implementations
Stripe
import Stripe from 'stripe';
import express from 'express';
Const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
App.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw buffer
sig,
endpointSecret
);
} catch (err: any) {
console.log(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event (with idempotency)
await handleStripeEvent(event);
res.json({ received: true });
}
);
Stripe's constructEvent() handles signature verification, timestamp validation, and constant-time comparison. Use it. Don't hand-roll.
Twilio
from twilio.request_validator import RequestValidator
from flask import request, abort
Validator = RequestValidator(os.environ['TWILIO_AUTH_TOKEN'])
@app.post('/webhooks/twilio/sms')
def handle_twilio_sms():
signature = request.headers.get('X-Twilio-Signature', '')
url = request.url
params = request.form.to_dict()
if not validator.validate(url, params, signature):
abort(403)
# Process verified webhook
process_sms(params)
return '', 200
Twilio's RequestValidator handles URL-based signing.
SendGrid
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';
Const ew = new EventWebhook();
App.post('/webhooks/sendgrid',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.get(EventWebhookHeader.SIGNATURE());
const ts = req.get(EventWebhookHeader.TIMESTAMP());
const publicKey = ew.convertPublicKeyToECDSA(
process.env.SENDGRID_WEBHOOK_PUBLIC_KEY!
);
if (!ew.verifySignature(publicKey, req.body, sig!, ts!)) {
return res.status(403).send('Invalid signature');
}
const events = JSON.parse(req.body.toString());
for (const event of events) {
handleSendGridEvent(event);
}
res.status(200).send();
}
);
SendGrid uses ECDSA signatures. Their SDK handles it correctly.
GitHub
import crypto from 'crypto';
App.post('/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-hub-signature-256'] as string;
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return res.status(403).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
handleGitHubEvent(req.headers['x-github-event'] as string, event);
res.json({ received: true });
}
);
GitHub uses HMAC-SHA256. The signature is in X-Hub-Signature-256.
Shopify
import crypto from 'crypto';
App.post('/webhooks/shopify',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-shopify-hmac-sha256'] as string;
const expected = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
.update(req.body)
.digest('base64');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return res.status(403).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
handleShopifyEvent(event);
res.json({ received: true });
}
);
Shopify uses HMAC-SHA256 with base64 encoding.
Slack
import crypto from 'crypto';
App.post('/webhooks/slack', express.urlencoded({ extended: true }), (req, res) => {
const signature = req.headers['x-slack-signature'] as string;
const timestamp = req.headers['x-slack-request-timestamp'] as string;
// Verify timestamp (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(403).send('Timestamp too old');
}
// Verify signature
const body = new URLSearchParams(req.body as any).toString();
const baseString = `v0:${timestamp}:${body}`;
const expected = 'v0=' + crypto
.createHmac('sha256', process.env.SLACK_SIGNING_SECRET!)
.update(baseString)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return res.status(403).send('Invalid signature');
}
// Handle verified webhook
handleSlackEvent(req.body);
res.status(200).send();
});
Slack uses a signed-timestamp construction that prevents replay.
The testing pattern
After implementing webhook verification, test it. Common tests:
# Test 1: no signature header → should reject
curl -X POST https://yourapp.com/webhooks/stripe \\
-H "Content-Type: application/json" \\
-d '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_test"}}}'
# Expect: 400 or 403
# Test 2: invalid signature → should reject
curl -X POST https://yourapp.com/webhooks/stripe \\
-H "Content-Type: application/json" \\
-H "Stripe-Signature: t=1234567890,v1=invalid_signature" \\
-d '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_test"}}}'
# Expect: 400 or 403
# Test 3: legitimate Stripe test webhook (via Stripe CLI) → should accept
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
# Expect: 200
# Test 4: replay of an old legitimate webhook → should reject
# (requires capturing a real webhook and modifying timestamp)
Architectural patterns
Beyond individual webhook handlers, consider architecture-level protections:
1. Webhook endpoints on separate subdomain
Isolate webhook endpoints from main application:
webhooks.yourapp.com. Webhook endpoints only
app.yourapp.com. Main application
Benefits:
- Different authentication model is visible from the URL
- Separate monitoring and rate limiting
- Easier to apply different security controls
2. Webhook queueing
Incoming webhooks go to a queue (SQS, Pub/Sub, Kafka) before processing:
Webhook HTTP endpoint → validate signature → queue event
↓
Worker processes events asynchronously
Benefits:
- HTTP endpoint is simple and fast (quick validation, quick response)
- Complex processing happens async
- Retry semantics are cleaner
- DDoS resilience is better
3. Mutual TLS for webhook sources
Some providers support mTLS where the webhook provider's TLS certificate proves their identity. Alternative to signature verification for highly sensitive scenarios.
4. IP allowlisting (with caveats)
Some webhook providers publish their IP ranges. You can allowlist those IPs on your webhook endpoint.
Caveats:
- IP ranges can change
- Some providers use shared IPs across customers
- Not a replacement for signature verification (attackers could send from an allowlisted IP via vulnerable systems in that range)
Use as defense-in-depth, not primary defense.
5. Dead letter queue monitoring
Failed webhooks (invalid signatures, processing errors) should go to a dead letter queue with alerting. Spike in signature failures = attack attempt. Spike in processing errors = bug.
Detection: finding forgery attempts in your logs
Specific log patterns to alert on:
- Signature verification failures above baseline
- Webhooks with recent timestamps but unusual source IPs
- Repeated requests with the same event ID (replay attempts)
- Unusual spike in webhooks for specific event types (targeted forgery)
- Webhook destinations that don't match expected application endpoints
Webhook failure alerting should be more aggressive than general error alerting. Normal operation produces few signature failures (legitimate webhook providers have low failure rates).
For Valtik clients
Valtik's application security audits include webhook implementation review:
- Inventory of webhook endpoints
- Signature verification implementation review
- Idempotency and race-condition analysis
- Replay attack testing
- Cross-source verification mistake detection
For B2B SaaS applications, fintech, e-commerce. And any product accepting webhooks, this review typically identifies at least one finding. Payment-handling applications often have multiple. Reach out via https://valtikstudios.com.
The honest summary
Webhook signature verification is the single most commonly skipped authentication control in modern web applications. It's not hard to implement correctly. Major providers' SDKs handle the complexity. The challenge is consistent application across every webhook endpoint, proper raw-body handling, timing-safe comparison, timestamp validation, idempotency. And revalidation against the source API where appropriate.
If your application has webhook endpoints and you haven't audited them against the patterns in this post, you likely have at least one forgeable webhook. Fix it before someone forges it.
Sources
- Stripe Webhook Signatures Documentation
- Twilio Request Validation
- SendGrid Webhook Security
- GitHub Webhook Signatures
- Shopify Webhook Verification
- Slack Request Signing
- OWASP Webhook Security Cheat Sheet
- HMAC RFC 2104
- Node.js crypto.timingSafeEqual Documentation
- Replay Attack Mitigation Patterns. NIST
Want us to check your Public Company setup?
Our scanner detects this exact misconfiguration. plus dozens more across 38 platforms. Free website check available, no commitment required.
