DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

Webhook Signature Bypass: When the Receiver Skips the HMAC Check

A POST request arrives at /api/webhooks/stripe. The body says checkout.session.completed: a $2,400 order succeeded. The server processes it, ships the order, provisions the account, credits the balance. Nobody at Stripe sent that request. The server had no way to know.

Skipping or misconfiguring webhook HMAC verification is not theoretical. Four production CVEs from 2025-2026 show attackers forging payment confirmations and triggering arbitrary workflows using only the endpoint URL. No API credentials required.

The Provider Signs, the Receiver Decides

Stripe sends Stripe-Signature: t=1234,v1=abc.... GitHub sends X-Hub-Signature-256: sha256=abc.... Slack sends X-Slack-Signature: v0=abc.... Each provider computes HMAC-SHA256 with a shared secret, but over different payloads: GitHub signs the raw request body directly, Stripe signs timestamp.rawBody (where timestamp comes from Stripe-Signature), and Slack signs the basestring v0:timestamp:rawBody.

HTTP has no mechanism to force the receiver to check the header. The spec is one-directional: the provider signs, the receiver decides whether to verify. Frameworks route the body regardless of whether Stripe-Signature exists or was validated.

GitHub documents this explicitly: absence of X-Hub-Signature-256 means no secret was configured, not that the event is valid. That transfer of responsibility is where systems fail.

Four Failures That Silently Disable Verification

Developers who plan to validate signatures routinely produce code that passes signature checks unconditionally. Four distinct patterns create complete bypasses through different mechanisms.

Failure 1: No check written. CVE-2026-21894 (n8n, CVSS 6.5): the Stripe Trigger node stored the signing secret, but the webhook() handler only matched the event type. Signature verification against Stripe-Signature was never called. Any POST to the webhook URL triggered the workflow. The fix shipped in n8n 2.2.2.

Failure 2: Empty secret accepted. CVE-2026-41432 (new-api, CVSS 7.1): when StripeWebhookSecret was empty or unset, the HMAC-SHA256 key was the empty string, making the function publicly computable. An attacker forged a valid signature without knowing the real secret and submitted checkout.session.completed events to credit unlimited quota. The fix required rejecting webhook setup when no secret is configured.

Failure 3: String equality timing leak. signature === computed in JavaScript or == in Python is not constant-time. Timing attacks recover the expected signature byte by byte by measuring response time differences. Use crypto.timingSafeEqual() in Node.js and hmac.compare_digest() in Python.

Failure 4: Body parsed before signature check. Express express.json() middleware re-serializes the parsed body for downstream handlers: the raw bytes Stripe signed are gone. Verification against re-serialized JSON fails, so developers silence the error instead of fixing the parse order. The fix is express.raw({ type: 'application/json' }) for webhook routes, computing the HMAC against the raw buffer.

A separate omission affects even developers who wrote the verification correctly: missing replay window. Stripe embeds t=timestamp in the signature header. Receivers must reject events where now - t > 300 seconds. Without this gate, a captured webhook replays indefinitely.

What Attackers Forge

The attack requires only the webhook endpoint URL, discoverable via directory enumeration at /webhook, /hooks, /stripe, /api/webhooks/stripe. The attacker constructs a POST with a forged event body and no valid signature. If the receiver skips verification, fulfillment logic runs.

Common targets: subscription activation (send customer.subscription.created), payment confirmation (send payment_intent.succeeded), CI/CD pipeline trigger (send a push event to a GitHub Actions webhook endpoint), user provisioning (send an identity provider account creation event).

CVE-2026-41432 is the most direct case: a forged checkout.session.completed event credited unlimited quota in new-api without any real payment. The webhook URL was documented in the configuration interface and accessible without authentication.

Four CVEs and a GitHub Platform Incident

CVE-2026-21894 (n8n, CVSS 6.5). The Stripe Trigger node accepted unsigned events. Any attacker with the webhook UUID triggered arbitrary workflow execution. Fixed in n8n 2.2.2.

CVE-2026-41432 (new-api, CVSS 7.1). Empty secret makes HMAC publicly computable. Forged checkout.session.completed events credited unlimited quota without payment. Fixed in new-api 0.12.10.

CVE-2025-11271 (Easy Digital Downloads, CVSS 5.3). PayPal IPN verification was unconditionally skipped when the POST body contained verification_override=1. Unauthenticated actors submitted forged IPNs treated as verified on production sites.

CVE-2026-77999 (J2Store). SSL verification disabled in the PayPal IPN post-back request. An interposing server returning VERIFIED for forged payments completed the bypass. A separate logic flaw treated UNVERIFIED as success by only checking for the presence of INVALID.

GitHub platform incident (September 11 to December 10, 2025, with a brief recurrence on January 5, 2026, resolved January 26). A GitHub platform bug leaked webhook secrets in the X-Github-Encoded-Secret header on HTTP responses for certain API endpoints. Receivers logging headers had signing secrets exfiltrated, enabling forgery of X-Hub-Signature-256. HashiCorp disclosed via HCSEC-2026-09: their HCP Terraform webhook secrets were exposed. GitHub rotated all affected secrets in January 2026.

PayPal IPN: When Verification Architecture Becomes the Attack Surface

PayPal IPN (Instant Payment Notification) uses a post-back model: the receiver sends the received body back to PayPal, and PayPal responds VERIFIED or INVALID. This model requires a valid SSL connection to PayPal's servers.

CVE-2026-77999 (J2Store) disabled SSL verification in the post-back request. An interposing server returning VERIFIED for any forged payment completed the bypass without any real contact with PayPal. A separate logic flaw treated UNVERIFIED as success by checking only for the presence of INVALID.

CVE-2025-11271 (Easy Digital Downloads) allowed treating UNVERIFIED responses as valid in certain code paths. Both failures are consequences of the post-back model: verification requires a network call, and that call has its own attack surface. HMAC-based signing eliminates this dependency. Verification is a local computation, no network round-trip required for the verification step itself.

Detection and Remediation

The correct order: capture raw bytes first, verify the signature, then parse the body. In Express, register an express.raw({ type: 'application/json' }) handler before express.json() for webhook routes, passing the raw buffer to the HMAC verification function.

// Extract only the hash portion from headers like Stripe-Signature: t=...,v1=<hash>
const provided = header.split(',').find(p => p.startsWith('v1=')).slice(3);
const computed = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

// Equal-length buffers required — timingSafeEqual throws on length mismatch
if (provided.length !== computed.length) return res.status(401).end();
const valid = crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(computed));
// Note: this is provider-agnostic pseudocode. Use stripe.webhooks.constructEvent() in production.
Enter fullscreen mode Exit fullscreen mode

Startup gate: if WEBHOOK_SECRET is empty, refuse to start or refuse to register the webhook endpoint with a fatal error. An empty secret is worse than no signature check: it gives developers false confidence that verification is active.

Return HTTP 400 for any request missing the signature header. Log the source IP and timestamp as a security event. Legitimate providers always send the header.

Replay window: reject events where the timestamp in the signature header is more than 300 seconds old. This prevents indefinite replay of captured webhooks.

The MAGO Intel tool (intel.mago.team) probes webhook endpoints for missing signature validation. It sends unsigned POST requests to common webhook paths and flags endpoints that return 200 without a valid signature header.

The attack requires no API credentials. It requires only the endpoint URL and the knowledge that signature verification is absent. Discovering that gap takes 30 seconds with curl. The fix is four lines of code applied in the correct order.

Top comments (0)