Why Signature Verification Is Non-Negotiable
Without signature verification, your webhook endpoint is an open door. Any attacker who knows your endpoint URL can POST a crafted payment_intent.succeeded payload and trigger your fulfilment logic — shipping products, granting access, or sending receipts — for payments that never happened.
Stripe signs every webhook with an HMAC-SHA256 digest of the raw request body, using a signing secret unique to each webhook endpoint. Verifying the signature proves the request came from Stripe's servers and hasn't been tampered with in transit.
Before diving in, make sure you can receive and inspect real Stripe payloads. If you haven't set that up yet, start with how to test Stripe webhooks locally first.
How the Stripe Signature Works
Stripe's signature scheme is slightly more complex than a simple HMAC. Each request includes a Stripe-Signature header in this format:
Stripe-Signature: t=1712000000,v1=abc123def456...,v0=legacy...
The fields:
t— Unix timestamp of when Stripe sent the eventv1— HMAC-SHA256 signature (current scheme)v0— legacy signature (ignore this)
The signed payload that Stripe computes the HMAC over is:
{timestamp}.{raw_request_body}
So the full verification flow is:
- Extract
tandv1from theStripe-Signatureheader - Concatenate:
{t}.{raw_body} - Compute HMAC-SHA256 of that string using your signing secret
- Compare to
v1— if they match, the request is authentic - Check that
tis within 300 seconds of now — replay attack protection
Stripe's SDK does all of this in one call. See the full Stripe webhook events reference for a list of events and their payload shapes.
Implementation — Node.js (Recommended)
The Stripe Node.js SDK handles verification natively. The one critical requirement: you must pass the raw request body as a Buffer, not a parsed JSON object.
import Stripe from 'stripe'
import express from 'express'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET // whsec_...
const app = express()
// ⚠️ This route must use raw body middleware — NOT express.json()
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
console.error('Signature verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// Signature verified — process the event
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object)
break
case 'customer.subscription.deleted':
await revokeAccess(event.data.object)
break
default:
console.log('Unhandled event type:', event.type)
}
res.json({ received: true })
}
)
Important: If you use express.json() globally, it will parse the body before this route can see the raw bytes. Either place the webhook route before your global middleware, or scope express.raw() specifically to this path.
Implementation — Python (Django / Flask)
import stripe
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
stripe.api_key = settings.STRIPE_SECRET_KEY
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
@csrf_exempt
def stripe_webhook(request):
payload = request.body # raw bytes — Django keeps these
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
except stripe.error.SignatureVerificationError as e:
return HttpResponseBadRequest(str(e))
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
fulfill_order(payment_intent)
return HttpResponse(status=200)
Implementation — Go
package main
import (
"io"
"log"
"net/http"
"os"
"github.com/stripe/stripe-go/v76/webhook"
)
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
const maxBodyBytes = int64(65536)
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
event, err := webhook.ConstructEvent(
payload,
r.Header.Get("Stripe-Signature"),
os.Getenv("STRIPE_WEBHOOK_SECRET"),
)
if err != nil {
log.Printf("Signature verification failed: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch event.Type {
case "payment_intent.succeeded":
// handle...
}
w.WriteHeader(http.StatusOK)
}
The Most Common Verification Failures
1. "No signatures found matching the expected signature"
Almost always caused by body parsing before verification. The HMAC is computed over the raw bytes. If any middleware has already parsed the body (JSON, urlencoded, etc.), the bytes have changed.
Fix: Use express.raw({ type: 'application/json' }) specifically on the webhook route. In Express, route-level middleware runs before global middleware if you order them correctly:
// Route-specific raw body — before global json()
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler)
// Global JSON for all other routes
app.use(express.json())
2. "Timestamp outside the tolerance zone"
The t timestamp in the signature header is more than 300 seconds old. Stripe rejects deliveries that take too long (server clock drift, extremely delayed processing). By default, constructEvent enforces this 5-minute tolerance.
Fix in development: Pass { tolerance: null } (or a larger value) to skip the timestamp check during local testing:
// In dev only — don't disable tolerance in production
event = stripe.webhooks.constructEvent(payload, sig, secret, 600) // 10 min tolerance
3. Wrong signing secret
Stripe creates a separate signing secret for each webhook endpoint — it starts with whsec_. This is different from your API secret key (sk_test_...) and different for each endpoint you create.
Fix: In the Stripe Dashboard → Developers → Webhooks → [your endpoint] → Signing secret. Reveal and copy it. Make sure STRIPE_WEBHOOK_SECRET in your environment matches the endpoint you're testing.
4. Using the Stripe CLI secret with a Dashboard endpoint
When you run stripe listen, it prints its own webhook signing secret (whsec_...). This secret only works for payloads forwarded by the CLI — not for events sent to a Dashboard-registered endpoint. They're not interchangeable.
5. Webhook secret from test mode used in live mode
Stripe test mode and live mode have separate webhook secrets. When you go to production, re-register the endpoint in live mode and use the live signing secret.
Testing Signature Verification Locally
The cleanest way to test end-to-end signature verification without a real Stripe account is:
- Create a free WebhookWhisper endpoint and register it in the Stripe Dashboard (test mode)
- Copy the signing secret Stripe shows you (
whsec_...) - Set
STRIPE_WEBHOOK_SECRET=whsec_...in your local environment - Add a forwarding rule from your WebhookWhisper endpoint to
http://localhost:3000/webhooks/stripe - In the Stripe Dashboard, click Send test event — pick
payment_intent.succeeded - The event arrives at WebhookWhisper, gets forwarded to your local handler, and your
constructEventcall verifies the real Stripe signature
This is the only way to test real signature verification end-to-end — WebhookWhisper's built-in Test Sender fires payloads without Stripe signatures, which is fine for testing handler logic but not signature verification.
Security Checklist
- ☑ Verify signature on every incoming webhook — no exceptions
- ☑ Use the raw body (Buffer/bytes), never a parsed object
- ☑ Store
STRIPE_WEBHOOK_SECRETin environment variables, never in code - ☑ Use separate secrets for test and live mode endpoints
- ☑ Return
200only after successful verification - ☑ Implement idempotency — same event can arrive more than once
- ☑ Respond within 30 seconds — enqueue heavy work, don't do it synchronously