Webhook Signature Verification: Stop Trusting Unsigned Payloads

Quick answer: Verify every webhook by computing the HMAC-SHA256 of the raw request body using your shared secret and comparing it to the provider's signature header with a timing-safe function. Add a five-minute timestamp tolerance and event-ID deduplication to prevent replay attacks. Without this, anyone can forge events to your endpoint.

By Daniel A · Kraftwire Software

· 9 min read

Verify every webhook by computing the HMAC-SHA256 of the raw request body using your shared secret and comparing it to the provider's signature header with a timing-safe function. Add a five-minute timestamp tolerance and event-ID deduplication to prevent replay attacks. Without this, anyone can forge events to your endpoint.

Key Takeaway

A webhook endpoint that does not verify signatures will believe anyone who can send an HTTP POST · and anyone can send an HTTP POST. Signature verification with HMAC proves the payload came from the provider and was not altered in transit. Build the habit with the HMAC generator, then make raw-body verification the first line of every webhook handler you ship.

Why Is an Unverified Webhook Endpoint an Open Door?

A webhook is just a URL on your server that a provider calls when something happens · a payment succeeds, a repo receives a push, or a subscription cancels. The problem is that the URL does not know who is calling. Endpoints like /api/webhooks/stripe are guessable, and they leak through logs, error trackers, and public documentation.

Now think about what your handler does when it trusts the payload. If it upgrades an account when it sees checkout.session.completed, an attacker can POST a forged Stripe event and get your product for free. If it triggers a deploy when it sees a GitHub push event, a forged push can make your pipeline pull and run whatever the attacker points it at. The payload is attacker-controlled input wearing a trusted provider's costume · which is exactly the class of problem covered in API security best practices.

The fix is not to hide the URL. Obscurity buys nothing durable. The fix is to make every request prove it knows a secret that only you and the provider share.

How Do HMAC Signatures Work?

HMAC (hash-based message authentication code) is the mechanism nearly every webhook provider uses. The idea fits in three sentences:

  • You and the provider share a secret string, shown once in the provider dashboard when you create the endpoint.
  • For every delivery, the provider computes HMAC-SHA256(secret, raw request body) and sends the result in a header.
  • Your server computes the same HMAC over the raw bytes it received and compares. A match proves the sender knows the secret and the body was not modified.

Because the hash covers the entire body, changing even one character of the payload changes the signature completely. Because computing a valid signature requires the secret, an attacker who knows your URL still cannot forge a passing request. Store that secret like any other server credential · in a server-side environment variable, never in frontend code · the same rules as environment variables security.

Which Two Signature Schemes Will You Actually Meet?

Stripe · t= and v1=

Stripe sends a Stripe-Signature header that looks like t=1719840000,v1=5257a86.... The t is a unix timestamp, and v1 is the HMAC-SHA256 of the string timestamp + "." + rawBody, keyed with your endpoint's signing secret (it starts with whsec_). Including the timestamp inside the signed payload is what makes replay protection possible. During secret rotation, Stripe can send multiple v1 entries, and your code should accept the request if any of them matches.

GitHub · sha256=

GitHub sends X-Hub-Signature-256: sha256=<hex digest>, where the digest is a plain HMAC-SHA256 over the raw body using the webhook secret you set on the repo. There is also a legacy X-Hub-Signature header using SHA-1 · ignore it and verify the SHA-256 one. Most other providers (Shopify, Slack, Twilio) follow one of these two shapes with cosmetic differences, so once you can verify Stripe and GitHub you can verify almost anything.

One more shape exists in the wild: a few providers sign webhooks as JWTs instead of bare HMAC digests. If the header value starts with eyJ, paste it into the JWT debugger to see the claims and the signing algorithm before you write a verifier.

How Do You Verify Webhook Signatures in Node?

Here is a GitHub verifier in Express. The two load-bearing details are the raw body and the timing-safe comparison:

Note that express.raw() is applied to this route only, so req.body arrives as the untouched Buffer the provider actually signed. For Stripe, prefer the official library's stripe.webhooks.constructEvent(rawBody, signatureHeader, secret) · it implements the t=/v1= parsing, the tolerance window, and the comparison for you.

Why Does Signature Comparison Need to Be Timing-Safe?

Comparing signatures with === returns as soon as the first byte differs, which means response time leaks how much of a guess was correct. Exploiting that over a noisy network is hard, but hard is not impossible, and the safe version is free: crypto.timingSafeEqual compares every byte regardless of where the mismatch is. It throws if the buffers differ in length, so check lengths first as the example does. There is no scenario where the fast-fail comparison is worth keeping.

How Do You Stop Webhook Replay Attacks?

A valid signature proves origin and integrity · it does not prove freshness. An attacker who captures one legitimate signed request can resend it unchanged tomorrow, and the signature will still verify. Two layers close this:

  • Check the timestamp. Stripe signs it into the payload precisely so you can reject events older than a tolerance window · five minutes is the common default. Compare against your server clock and refuse anything stale.
  • Track event IDs. Providers send a unique ID per event (evt_... for Stripe, the delivery GUID for GitHub). Store recently processed IDs and skip duplicates. This also makes your handler idempotent, which you want anyway because providers retry deliveries on timeouts.

What Mistakes Break Webhook Verification?

  • Verifying the parsed body. This is the classic. JSON.parse followed by JSON.stringify can reorder keys, change whitespace, and re-encode unicode · the bytes no longer match what was signed, verification fails on perfectly legitimate events, and a frustrated developer "fixes" it by deleting the check. Verify the raw bytes, always.
  • Letting a global express.json() middleware consume the body before your webhook route sees it. Mount express.raw() on the webhook path specifically.
  • Using the wrong secret. Stripe issues a distinct whsec_ per endpoint, and test mode and live mode have different ones. A signature that never verifies usually means the wrong secret, not an attack.
  • Comparing with === instead of a constant-time function.
  • Returning 200 before verifying, or leaking why verification failed in the error body. Respond 401 with nothing useful in it.

Beyond Webhooks: Full Site Health

Securing your webhooks is a vital step, but it is only one part of modern application health. If your webhook handler is slow, it might cause the provider to timeout and retry, leading to duplicate processing if you haven't implemented the idempotency checks mentioned above.

To ensure your app is resilient against more than just forged payloads, you should check for CSRF protection and ensure your environment variables are not leaking. You can run a free security scan to check 8 dimensions including security, speed, and GDPR compliance in ~30 seconds.

How Do You Prove Verification Works Before You Ship?

Do not wait for a real provider event to find out your verifier is wrong. Take a sample payload, compute its HMAC-SHA256 with the HMAC generator using your test secret, and send it to your endpoint with curl · then flip one character in the body and confirm you get a 401. Two minutes of that beats a week of silently accepted forgeries. And once your webhooks are locked down, check the rest of the surface · use the SimplyScan security scanner to see what else your app exposes.

FAQ

  • Do I need to verify webhook signatures if my endpoint URL is secret?

Yes. Hiding the URL buys nothing durable: paths like /api/webhooks/stripe are guessable and leak through logs, error trackers, and public documentation anyway. Signature verification makes every request prove it knows a secret only you and the provider share, so an attacker who discovers the URL still cannot forge a passing payload.

  • Why does my Stripe webhook signature verification keep failing on real events?

It is almost always the wrong secret or the wrong bytes, not an attack. Stripe issues a distinct whsec_ secret per endpoint, and test mode and live mode use different ones. The other classic is verifying a parsed body: JSON.parse followed by stringify reorders keys and changes whitespace, so the bytes no longer match what Stripe signed. Verify the raw body and use stripe.webhooks.constructEvent.

  • Is HTTPS enough to secure a webhook endpoint?

No. TLS protects the payload in transit, but it does nothing to authenticate the sender: anyone can send an HTTPS POST to your URL. HMAC signature verification is what proves the request came from the provider and was not altered, because computing a valid signature requires the shared secret. Use HTTPS and signature verification together, plus replay protection for captured requests.

  • Can an attacker reuse a captured webhook request even if it is signed?

Yes. A valid signature proves origin and integrity, not freshness, so a legitimate signed request captured today still verifies tomorrow. Close this with two layers: reject events whose signed timestamp is older than a tolerance window, five minutes being the common default, and store recently processed event IDs so duplicates are skipped. The ID check also makes your handler idempotent, which you want because providers retry deliveries.

  • Do Shopify, Slack, and Twilio webhooks work the same way as Stripe and GitHub?

Mostly, yes. Nearly every provider uses HMAC-SHA256 over the raw request body, following either GitHub's plain digest shape or Stripe's timestamped t= and v1= shape with cosmetic differences, so once you can verify those two you can verify almost anything. A few providers sign webhooks as JWTs instead; if the header value starts with eyJ, decode it to see the claims and algorithm first.

  • How long does it take to add webhook signature verification?

About ten lines of code done correctly: read the raw body, compute HMAC-SHA256 with your secret, and compare with a timing-safe function like crypto.timingSafeEqual. Stripe's official library does the parsing, tolerance window, and comparison for you via constructEvent. Testing takes two minutes: sign a sample payload with a free HMAC generator like SimplyScan's, curl it to your endpoint, then flip one character and confirm you get a 401.

Frequently asked questions

Do I need to verify webhook signatures if my endpoint URL is secret?

Yes. Hiding the URL buys nothing durable: paths like /api/webhooks/stripe are guessable and leak through logs, error trackers, and public documentation anyway. Signature verification makes every request prove it knows a secret only you and the provider share, so an attacker who discovers the URL still cannot forge a passing payload.

Why does my Stripe webhook signature verification keep failing on real events?

It is almost always the wrong secret or the wrong bytes, not an attack. Stripe issues a distinct whsec_ secret per endpoint, and test mode and live mode use different ones. The other classic is verifying a parsed body: JSON.parse followed by stringify reorders keys and changes whitespace, so the bytes no longer match what Stripe signed. Verify the raw body and use stripe.webhooks.constructEvent.

Is HTTPS enough to secure a webhook endpoint?

No. TLS protects the payload in transit, but it does nothing to authenticate the sender: anyone can send an HTTPS POST to your URL. HMAC signature verification is what proves the request came from the provider and was not altered, because computing a valid signature requires the shared secret. Use HTTPS and signature verification together, plus replay protection for captured requests.

Can an attacker reuse a captured webhook request even if it is signed?

Yes. A valid signature proves origin and integrity, not freshness, so a legitimate signed request captured today still verifies tomorrow. Close this with two layers: reject events whose signed timestamp is older than a tolerance window, five minutes being the common default, and store recently processed event IDs so duplicates are skipped. The ID check also makes your handler idempotent, which you want because providers retry deliveries.

Do Shopify, Slack, and Twilio webhooks work the same way as Stripe and GitHub?

Mostly, yes. Nearly every provider uses HMAC-SHA256 over the raw request body, following either GitHub's plain digest shape or Stripe's timestamped t= and v1= shape with cosmetic differences, so once you can verify those two you can verify almost anything. A few providers sign webhooks as JWTs instead; if the header value starts with eyJ, decode it to see the claims and algorithm first.

How long does it take to add webhook signature verification?

About ten lines of code done correctly: read the raw body, compute HMAC-SHA256 with your secret, and compare with a timing-safe function like crypto.timingSafeEqual. Stripe's official library does the parsing, tolerance window, and comparison for you via constructEvent. Testing takes two minutes: sign a sample payload with a free HMAC generator like SimplyScan's, curl it to your endpoint, then flip one character and confirm you get a 401.

Related guides

  • DNSSEC and CAA: Stop Attackers From Hijacking Your Domain · DNSSEC signs your DNS records so resolvers reject forged answers, stopping cache-poisoning redirects. CAA records restrict which certificate authorities may issue HTTPS certificates for your domain, blocking attacker-obtained certs. Both are free: enable DNSSEC at your DNS host, publish the DS record at your registrar, then add CAA records naming only the CAs you use.
  • Environment Variables Security: Stop Leaking Secrets to Production · Environment variables only protect secrets when used correctly. Any variable prefixed VITE_, NEXT_PUBLIC_, or REACT_APP_ is embedded in your JavaScript bundle and readable by every visitor. Keep API keys, service role keys, and database URLs server-side without a public prefix and verify your bundle contains no secrets.
  • 60 Free Security & Developer Tools Every Vibe Coder Should Bookmark · Sixty free, no-signup tools cover the security and visibility gaps AI app generators leave behind. These include live checks for SSL, security headers, and exposed .env files, plus browser-local utilities like JWT debuggers and secret scanners. Run these checks after every deploy to ensure your vibe-coded app is production-ready.
  • A Security Headers Checklist for AI-Built Apps · A security headers checklist for 2026 must include Content-Security-Policy (CSP), HSTS with preloading, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. These headers prevent XSS, clickjacking, and data leaks. SimplyScan's data shows 33% of AI-built apps have high-severity issues, often due to missing these essential browser-level protections.

All security guides · Free security tools · Platform scanners · Security checklist