← Back to Blog Security

Webhook Signature Verification: The Complete Guide for Stripe, GitHub & Shopify (2026)

Debugging a failed webhook right now?

Paste the raw body, signature header, and secret into our Webhook Signature Verifier — it recomputes the HMAC locally in your browser and tells you exactly whether the signature matches, for Stripe, GitHub, Shopify, and custom HMAC setups.

Your app just received a webhook: payment_intent.succeeded, time to ship the product. Before your code touches that JSON, one question decides whether this is a real Stripe event or an attacker telling you a package was paid for: can you prove who sent this? Signature verification is that proof, and getting it wrong — or skipping it — is one of the most common and most exploitable mistakes in modern backend development.

1. Why Unverified Webhooks Are an Open Door

A webhook endpoint is, structurally, a public URL on your domain that accepts POST requests containing JSON. Anyone who discovers it — through leaked logs, brute-forced paths, or a careless frontend leak — can POST anything they want. Without verification, an attacker can:

  • Forge payments. Send a fake payment_intent.succeeded and receive goods for free. This exact attack has drained real e-commerce stores.
  • Trigger destructive actions. Fake a push event on a CI endpoint and run arbitrary pipeline code with your secrets attached.
  • Poison your data. Inject fabricated events that corrupt analytics, invent user actions, or manipulate inventory counts.
  • Probe your internals. Error messages from unvalidated payloads leak stack traces, library versions, and schema details.

The defense every provider settled on is the same: the sender signs each request with a shared secret using HMAC, and your server recomputes that signature before trusting a single byte.

2. How HMAC Signing Actually Works

HMAC (Hash-based Message Authentication Code) combines a secret key with the message body through a cryptographic hash (almost always SHA-256) to produce a fixed-length signature. Two properties make it perfect for webhooks:

  1. Only key-holders can produce it. The signature depends on the secret; without it, forging a valid signature for even a one-byte-changed payload is computationally infeasible.
  2. Any change breaks it. Flip one comma in the body and the signature changes completely — so the signature proves both origin and integrity.

Verification is always the same three steps, regardless of provider: recompute the HMAC over the exact bytes received using your copy of the secret, compare it to the signature the sender attached, and only then parse and process the payload. The subtlety — and where nearly all real-world bugs live — is the phrase exact bytes received. More on that in section 6.

One more detail separates professionals from tutorials: comparison must be constant-time. A plain === exits at the first differing character, which leaks timing information about how many leading characters matched. Use your language's constant-time compare (Node's crypto.timingSafeEqual, Python's hmac.compare_digest) so the check leaks nothing.

3. Verifying Stripe Webhooks (with Replay Protection)

Stripe is the most instructive case because its signature scheme adds replay protection on top of authenticity. Every delivery carries a header like:

Stripe-Signature: t=1724689200,v1=5f7a1b8c9d0e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a

Two components, comma-separated: a Unix timestamp and one or more v1 signatures. The verification procedure:

  1. Extract t and v1 from the header.
  2. Build the signed payload by joining the timestamp, a dot, and the raw body: t + "." + body.
  3. Compute HMAC-SHA256(signedPayload, whsec_...) — note the secret starts with whsec_ and is used verbatim.
  4. Compare the result to v1 using a constant-time comparison.
  5. Check that the timestamp is within your tolerance window (Stripe recommends 5 minutes). Outside the window, reject — even though the signature itself is valid — because this is evidence of a replay.

The timestamp check is what defeats replay attacks: an attacker who captures a legitimate invoice.paid webhook cannot replay it next week, because your server rejects anything older than the tolerance. The timestamp is inside the signed payload, so it cannot be forged independently.

Stripe's official libraries (stripe.webhooks.constructEvent in Node, stripe.Webhook.construct_event in Python) implement all five steps. Use them — but understand the steps, because when verification fails in production you need to know which one broke.

4. Verifying GitHub Webhooks

GitHub keeps it simpler: the X-Hub-Signature-256 header contains sha256= followed by a plain HMAC-SHA256 of the raw body, keyed with the secret you typed into the webhook settings. Verification:

const expected = 'sha256=' + hmacSha256(rawBody, secret);
const valid = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers['x-hub-signature-256'])
);

GitHub has no timestamp component, so replay protection is your responsibility — if a deployment webhook triggers infrastructure changes, add your own event-ID deduplication or timestamp window at the application layer. Also note GitHub offers a legacy SHA-1 header (X-Hub-Signature); verify only the SHA-256 one and ignore the old header entirely.

5. Verifying Shopify Webhooks

Shopify differs in two ways. First, the header is X-Shopify-Hmac-Sha256 and the signature is base64-encoded, not hex — a classic source of mismatch bugs when developers compare a base64 signature against a hex computation. Second, the HMAC is computed over the raw body with your app's API secret key (not a separate webhook secret):

const computed = crypto
  .createHmac('sha256', SHOPIFY_API_SECRET)
  .update(rawBody, 'utf8')
  .digest('base64');
const valid = crypto.timingSafeEqual(
  Buffer.from(computed),
  Buffer.from(req.get('X-Shopify-Hmac-Sha256'), 'base64')
);

Shopify also recommends verifying the webhook topic and shop domain headers, and — for high-security flows — performing an OAuth check on the shop. For most apps, the base64 HMAC over the raw body is the core gate.

6. The Number One Bug: Parsed Bodies That Never Verify

Here is the failure that fills Stack Overflow: your endpoint verifies perfectly in tests, then every production webhook fails. Or the inverse — verification "passes" for attackers because you signed the wrong thing. The cause is almost always the same: the body was parsed and re-serialized before verification.

The signature was computed over the exact bytes the sender transmitted. If your framework parsed the JSON into an object and your verification code re-stringified it, the bytes may differ in any of these invisible ways: key order changed, whitespace collapsed, unicode re-escaped, numbers reformatted (1.0 becoming 1). The recomputed hash then matches nothing — valid webhooks fail, and confused developers "fix" it by disabling verification, which is the worst possible outcome.

The fix in Express is registering the raw-body parser before the JSON parser for the webhook route:

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handler);

Every framework has its own switch for this. Here is the reference for the five you are most likely using:

FrameworkHow to get the raw body
Express / Nodeexpress.raw({ type: 'application/json' }) middleware on the webhook route only
Next.js (App Router)const raw = await req.text() — Request bodies are raw by default; do not call req.json() first
FastifyAdd contentTypeParser for application/json with asBuffer: true on the webhook route, or set bodyLimit and read req.rawBody via the official plugin
Djangorequest.body is raw by default — the mistake is reading json.loads(request.POST); verify against request.body directly
Railsrequest.raw_post — available before any params access touches the body

Whatever the framework, the rule is absolute: capture bytes first, verify, and only then parse.

When you need to prove whether a mismatch is on your side or the sender's, isolate it: take the exact raw body and header from your logs, paste them into the Webhook Signature Verifier, and it recomputes the HMAC in your browser. If it matches there but not in your app, your framework is mutating the body — and you know precisely where to look.

7. Testing Webhooks Locally Without Pain

Verification code that is never tested will fail on the day it matters. The standard local workflow:

  1. Expose localhost. Use a tunnel (ngrok, Cloudflare Tunnel, or stripe listen which tunnels automatically) so the provider can reach your dev machine.
  2. Use the provider's CLI to fire real signed events. stripe trigger payment_intent.succeeded sends a properly signed test event through stripe listen, which also prints the local signing secret for your dev endpoint. This exercises your full verification path — not a mock.
  3. Test the failure paths deliberately. Tamper with one byte of the body and confirm your endpoint rejects. Replay an old event past your timestamp window and confirm rejection. A verification path that has never seen an invalid input is untested.
  4. Check idempotency. Providers retry on timeout — your handler will receive the same event twice. Store processed event IDs and skip duplicates; verification confirms authenticity, not uniqueness.

One more production note: respond fast. Providers time out in 10–30 seconds and retry with backoff. Acknowledge the webhook (queue it, return 200) and process heavy work asynchronously — a slow handler turns into a retry storm that hammers your own API.

8. Five Mistakes That Let Attackers In

MistakeConsequenceCorrect approach
No verification at all ("the URL is secret")URLs leak; forged events ship products freeVerify every delivery, every time
Verification against re-serialized JSONValid webhooks fail → dev disables verification under pressureVerify the raw bytes, then parse
Non-constant-time comparisonTiming leaks enable byte-by-byte forgeryUse timingSafeEqual / compare_digest
Skipping timestamp checks (Stripe)Captured webhooks replayable foreverEnforce a 3–5 minute tolerance window
Secrets in frontend code or logsSigning secret leaks = signatures forgeable by anyoneSecrets live only server-side, in env vars

A sixth worth naming: logging full webhook bodies including sensitive payloads. Logs are read more widely than code; treat webhook content like the credentials-adjacent data it often is.

9. Debugging Checklist When Signatures Fail

  1. Confirm the secret. Test-environment events signed with test secrets fail against production secrets. Stripe secrets start whsec_; Shopify uses the app API secret, not a webhook-specific one.
  2. Confirm raw bytes. Log typeof body — if it is an object before verification, your framework already broke the bytes (section 6).
  3. Check encoding. Hex vs base64 (Shopify), and the sha256= prefix (GitHub) must be handled exactly.
  4. Check the timestamp window. A valid signature with an old timestamp is a replay — reject it, but know that clock skew between servers can also trigger this; keep server clocks NTP-synced.
  5. Reproduce outside the app. The browser-based verifier recomputes independently of your stack; a match there localizes the bug to your framework's body handling.
  6. Rotate if exposed. If a secret ever hit a log, a ticket, or a chat message, rotate it — Stripe's overlapping-secret rotation makes this zero-downtime.

Conclusion

Webhook signatures are the difference between an event stream you can trust and a public API for attackers to manipulate. The mechanics are uniform across providers — HMAC over the raw body, constant-time compare, plus Stripe's timestamp for replay defense — and the failure modes are just as uniform: mutated bodies, skipped verification, and leaked secrets. Internalize the raw-bytes rule and the rest is bookkeeping.

Keep the Webhook Signature Verifier bookmarked for the day a signature mysteriously fails — it recomputes HMACs for Stripe, GitHub, Shopify, and custom schemes entirely in your browser. To go deeper on the primitives behind all of this, read our HMAC Generator guide page, and for the broader key-handling picture, see HMAC vs Hashing vs Encryption and Web Security Complete Guide.

Frequently Asked Questions

Why do webhooks need signature verification?
A webhook endpoint is a public URL that accepts POST requests from anyone on the internet. Without verifying the sender's signature, any attacker who discovers the URL can forge payments, fake deployments, or inject malicious data into your system.
Why must I verify the raw request body, not the parsed object?
The signature was computed over the exact bytes the sender transmitted. If your framework re-serializes a parsed JSON object, key order or whitespace may differ, and the recomputed hash will never match, causing valid webhooks to fail verification.
What is the Stripe-Signature header format?
Stripe sends a header containing a timestamp and one or more HMAC-SHA256 signatures in the form t=timestamp,v1=signature. The signature is computed over the string timestamp.body using your endpoint's signing secret, and the timestamp lets you reject replayed events.
How does GitHub sign its webhooks?
GitHub sends an X-Hub-Signature-256 header containing sha256= followed by an HMAC-SHA256 of the raw body, keyed with the webhook secret you configured. Verify by recomputing the HMAC over the raw body and comparing with a constant-time comparison.
What is a replay attack on webhooks?
An attacker captures a legitimate signed webhook and retransmits it later. Since the signature is still valid, an unprepared system processes it again. Timestamp checks with a tolerance window are the standard defense, and Stripe includes the timestamp inside the signed payload.
Why use a constant-time comparison for signatures?
A normal string comparison exits at the first mismatched character, leaking how many leading characters were correct. Attackers can exploit that timing signal byte by byte. Constant-time functions compare all characters regardless, eliminating the leak.
Should I rotate webhook signing secrets?
Yes — treat signing secrets like credentials. Stripe supports overlapping secrets during rotation: add the new secret, deploy, then remove the old one, so no webhook fails during the transition.
Can I verify webhooks in the browser without a backend?
Yes, for testing and debugging. The Web Crypto API computes HMAC-SHA256 locally, so a browser-based webhook verifier can confirm whether a payload and signature match without the secret ever leaving your machine.