Valtik Studios
Back to blog
Public Companyhigh2026-02-1311 min

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.

TT
Tre Trebucchi·Founder, Valtik Studios. Penetration Tester

Founder of Valtik Studios. Pentester. Based in Connecticut, serving US mid-market.

The attack that always works

In our experience working with mid-market clients, the gap is always wider than the paper-based assessment suggests.

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:

  1. Attacker captures one legitimate webhook (via network observation, server logs, etc.)
  2. Replays it weeks or months later
  3. 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

  1. [Stripe Webhook Signatures Documentation](https://stripe.com/docs/webhooks/signatures)
  2. [Twilio Request Validation](https://www.twilio.com/docs/usage/webhooks/webhooks-security)
  3. [SendGrid Webhook Security](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features)
  4. [GitHub Webhook Signatures](https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks)
  5. [Shopify Webhook Verification](https://shopify.dev/docs/apps/webhooks/configuration/https)
  6. [Slack Request Signing](https://api.slack.com/authentication/verifying-requests-from-slack)
  7. [OWASP Webhook Security Cheat Sheet](https://cheatsheetseries.owasp.org/)
  8. [HMAC RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104)
  9. [Node.js crypto.timingSafeEqual Documentation](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b)
  10. [Replay Attack Mitigation Patterns. NIST](https://csrc.nist.gov/)
webhook securitystripetwiliosendgridsignature verificationapi securitypenetration testingapplication securityresearch

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.

Get new research in your inbox
No spam. No newsletter filler. Only new posts as they publish.