Webhook Accepted Without Signature Verification
The payment webhook endpoint POST /api/webhooks/payment processes any incoming request without verifying the provider's HMAC-SHA256 signature. A forged webhook event claiming a payment succeeded for order 701 can be sent by any HTTP client and will be processed as a legitimate payment, crediting an order without a real charge having occurred.
HighIntermediateSecurity testingAPI testingManual testing
// UNDERSTAND
// Symptoms
- A POST to /api/webhooks/payment with a forged payment.succeeded body and no Stripe-Signature header transitions order 701 from 'pending' to 'paid'
- The webhook endpoint returns 200 OK for requests that include an invalid or missing signature header
- An order can be marked as paid without a corresponding charge appearing in the payment provider's test dashboard
- The application logs show webhook events being processed without any signature verification step
// Root Cause
- The webhook handler does not read the provider's signature header (e.g. Stripe-Signature) and does not compute and compare the expected HMAC-SHA256 from the raw request body and the webhook signing secret. Every POST to the endpoint is processed as a genuine provider event.
- The webhook signing secret is either not configured in the application's environment variables or the signature verification code path exists but is never invoked — the webhook handler was implemented in a development environment where verification was temporarily disabled and never re-enabled.
// Where It Appears
- Payment webhook endpoints in any application that integrates with Stripe, PayPal, or similar providers
- Subscription billing webhook handlers that process refund, cancellation, and dispute events
- Applications where the webhook endpoint was added quickly and signature verification was deferred as a 'later' task
- Environments where the webhook secret was set in production but not in staging, leaving staging unprotected
// REPRODUCE & TEST
// How to Reproduce
- 01Find a test order with ID 701 and confirm its status is 'pending' via GET /api/orders/701
- 02Construct a forged webhook body: { "type": "payment.succeeded", "data": { "object": { "metadata": { "orderId": "701" } } } }
- 03Send POST /api/webhooks/payment with the forged body and the Content-Type: application/json header — include no Stripe-Signature header or use a fabricated value such as 'fake-signature'
- 04Send GET /api/orders/701 and read the status field
- 05If the status changed from 'pending' to 'paid', the forged event was accepted without signature verification
// Test Data Needed
- A test order 701 in 'pending' status
- A way to send a raw HTTP POST request with a controlled body and headers (Postman or curl)
// Manual Testing Ideas
- Send the forged payment.succeeded event with no signature header and observe whether the order status changes
- Send the event with a Stripe-Signature header set to a random string (e.g. 'invalid-sig') and observe whether the application still processes it
- Compare with the provider's legitimate webhook: generate a correctly signed event using the Stripe CLI (stripe trigger payment_intent.succeeded) and confirm that it is also accepted — this confirms the endpoint works for real events while also verifying the bug for fake ones
- Check the application logs: does a signature verification step appear in the log before the order update? Its absence confirms no verification is performed
- Test whether the endpoint returns a different status code for missing vs. invalid vs. valid signatures — a production-hardened endpoint should return 400 for invalid signatures
// API Testing Ideas
- Confirm GET /api/orders/701 returns status: 'pending'
- Send POST /api/webhooks/payment with forged body { "type": "payment.succeeded", "data": { "object": { "metadata": { "orderId": "701" } } } } and no Stripe-Signature header
- Assert the response is 400 Bad Request — a correctly implemented endpoint must reject requests with missing or invalid signatures
- If the response is 200, send GET /api/orders/701 and confirm whether the status changed to 'paid' — a status change confirms the forged event was fully processed
// Automation Idea
Send POST /api/webhooks/payment with a forged payment.succeeded body for order 701 and no Stripe-Signature header. Assert the HTTP response is 400 Bad Request. Then send GET /api/orders/701 and assert the status remains 'pending'. If the webhook endpoint returns 200 and the order status changes to 'paid', the signature verification is absent and the test fails.
// Expected Result
POST /api/webhooks/payment rejects any request without a valid Stripe-Signature header with HTTP 400 Bad Request, and the order status is not changed.
// Actual Result (Example)
POST /api/webhooks/payment with a forged payment.succeeded body and no Stripe-Signature header returns 200 OK. GET /api/orders/701 returns { "status": "paid" } — the forged event was processed and the order was credited without a real charge.
// REPORT IT
Example Bug Report
- Title
- Forged payment.succeeded webhook transitions order 701 to 'paid' with no signature header
- Severity
- High
- Environment
- Staging environment Postman Test order 701 in 'pending' status Endpoint: POST /api/webhooks/payment
- Steps to Reproduce
- 01Confirm GET /api/orders/701 returns status: 'pending'
- 02Send POST /api/webhooks/payment with body { "type": "payment.succeeded", "data": { "object": { "metadata": { "orderId": "701" } } } } and no Stripe-Signature header
- 03Observe the HTTP response status code
- 04Send GET /api/orders/701 and read the status field
- Expected Result
- The webhook endpoint returns 400 Bad Request and the order status remains 'pending'.
- Actual Result
- The webhook endpoint returns 200 OK and GET /api/orders/701 returns status: 'paid'. The forged event was processed without any signature check.
- Impact
- An attacker can mark any order as paid without making a real payment, fraudulently obtaining goods or services. Refund events can also be forged to trigger bogus refunds, causing financial loss.