Verify webhook signatures on every inbound request: recompute the provider's HMAC over the raw request bytes and compare it using a timing-safe function before you parse or process anything. Yes, you need this on every webhook endpoint that touches money, user data, or account state. Skip it, and any endpoint that accepts POST requests will accept forged ones too.
TL;DR:
- Always capture the raw request body as bytes before parsing to ensure accurate HMAC comparison and prevent signature mismatch issues.
- Use HMAC with SHA-256 and compare signatures with a constant-time function to prevent timing attacks and ensure secure verification.
- Include timestamps and event IDs in signatures and validate their freshness and uniqueness to prevent replay attacks and duplicates.
- Log verification outcomes and payload hashes but never log secrets or raw header values to avoid exposing sensitive information.
- Test webhook verification thoroughly with known payloads and monitor invalid signature rates to detect potential attacks or misconfigurations.
Table of Contents
- How Does Webhook Signature Verification Work?
- Which Hash Algorithms Should You Use?
- What Are the Steps to Validate Webhook Signatures?
- How Do You Verify Signatures in Node.js, Python, Go, Java, and Ruby?
- How Do Timestamps and Deduplication Stop Replay Attacks?
- Why Do Signature Checks Fail, and How Do You Debug Them?
- What Else Belongs in a Defense-in-Depth Webhook Setup?
- How Should You Test and Monitor Webhook Verification?
- What I've Learned Building Verification Into Payment Flows
- Where Vopify Fits Into a Signed Payment Webhook Pipeline
- Sources
How Does Webhook Signature Verification Work?
Signature verification relies on HMAC, a keyed hash that combines a shared secret with a hash function to produce a value that only someone holding the secret can reproduce. Think of it as a wax seal that both sender and receiver can check, but only the sender can stamp. Because the secret never travels in the request, an attacker who intercepts or replays a payload cannot forge a matching signature without also stealing that key.
Here's what actually happens on the wire. The provider (Stripe, GitHub, your payment processor, whoever) takes the raw outgoing payload, sometimes concatenated with a timestamp, and runs it through HMAC using a secret you both share. That result gets attached as a header, commonly something like X-Signature or Stripe-Signature. Your server receives the request and does the exact same computation independently: same algorithm, same secret, same raw bytes. If your calculated value matches the header value, the payload came from the provider and was not altered in transit. If it doesn't match, either the secret is wrong, the bytes changed, or someone is trying to fake a request.
The part developers get wrong most often isn't the cryptography. It's what gets hashed. The provider signs the exact byte sequence it sent, not some idealized version of the JSON. Your framework's request parser, if it touches the body before you capture the raw bytes, will break this every time.
Which Hash Algorithms Should You Use?
Use HMAC paired with SHA-256 unless a provider specifically requires something else. SHA-2 is the current standard for integrity checks, and HMAC-SHA256 is what you'll find behind the vast majority of webhook signing schemes in production today. Avoid SHA-1 and MD5 for anything new; both have known weaknesses that make them poor choices even inside an HMAC construction, and providers are steadily deprecating them.
Some providers use asymmetric signatures instead of a shared secret, typically RSA or Ed25519. In that model, the provider signs with a private key and you verify with a public key or certificate they publish, usually rotated periodically. You'll see this more with platforms handling regulated financial data, where key distribution and non-repudiation matter more than the added complexity.

Header formats vary widely. You'll encounter sha256=<hex>, v1=<hex>,t=<timestamp>, and base64-encoded variants depending on the provider. Always confirm the exact signing string and encoding in that provider's docs before writing verification code. Assuming one provider's format applies to another is a fast way to burn an afternoon debugging a "broken" signature that was never broken.
What Are the Steps to Validate Webhook Signatures?
The order of operations matters more than the code itself. Get any step out of sequence and verification either fails on legitimate requests or, worse, passes on forged ones.
- Capture the raw request body as bytes before any JSON parsing, form decoding, or middleware touches it.
- Extract the signature header, plus any timestamp or event-ID headers the provider sends alongside it.
- Reconstruct the exact string that was signed, including the timestamp if the provider's scheme requires it concatenated with the payload.
- Compute HMAC over that string using the configured algorithm (HMAC-SHA256, in most cases) and your shared secret.
- Compare your computed value against the header value using a constant-time function, never a standard
==or string comparison. - Reject with a 401 on any mismatch, and reject before you do anything else with the payload.
- Check the timestamp against a tolerance window and check the event ID against a store of recently processed IDs, rejecting stale or duplicate events.
Pro Tip: Log the outcome of step 6 (pass or fail, plus a hash of the payload) but never log the secret, the raw header value, or the full computed signature. A leaked log file with valid signatures in it is almost as dangerous as a leaked secret.
Step 5 deserves its own emphasis. A naive string comparison exits early on the first mismatched byte, which means the time it takes to fail leaks information about how many leading bytes were correct. Repeated requests let an attacker reconstruct the expected signature byte by byte. That's a timing attack, and it's exactly why every major runtime ships a dedicated constant-time comparison function.
How Do You Verify Signatures in Node.js, Python, Go, Java, and Ruby?
The pattern repeats across every language: get the raw body, compute HMAC, compare with a timing-safe function. The differences are mostly in how each stack gets you those raw bytes.
- Node.js: mount
express.raw({ type: '*/*' })on the webhook route soreq.bodystays aBufferinstead of parsed JSON, then usecrypto.createHmac('sha256', secret).update(req.body).digest('hex')and compare it with crypto.timingSafeEqual. Watch for global body-parsing middleware applied earlier in the chain; it will consume the stream before your raw handler ever sees it. - Python: in Flask, read
request.get_data()before touchingrequest.json; in an ASGI app, read the body from the raw ASGI scope. Compute withhmac.new(secret, body, hashlib.sha256).hexdigest()and compare usinghmac.compare_digest, never==. Encode your secret consistently (bytes, not str) or you'll get silent mismatches. - Go: read
r.Bodyinto a byte slice withio.ReadAllbefore any decoding step, compute withcrypto/hmacandcrypto/sha256, and compare withhmac.Equal, which is constant-time by design. - Java: instantiate
Mac.getInstance("HmacSHA256"), initialize with aSecretKeySpecbuilt from the raw secret bytes (not a base64 string unless the provider says otherwise), and compare digests withMessageDigest.isEqualrather thanArrays.equals. - Ruby: use
OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)and compare withActiveSupport::SecurityUtils.secure_compareor an equivalent constant-time helper. Avoidto_jsonon a parsed body; that re-serialization almost never matches the provider's original bytes.
How Do Timestamps and Deduplication Stop Replay Attacks?
A valid signature only proves the payload is authentic. It says nothing about when it was sent, which means a captured request, replayed later, still passes signature checks. That's why most serious signing schemes fold a timestamp into the signed string and expect you to reject anything outside a tolerance window, typically five minutes.
Pair that with deduplication by event ID. Store recently processed IDs (Redis with a TTL works fine) and reject or no-op anything you've already handled, since retries and network hiccups mean the same event often arrives more than once. When rotating secrets, accept both the old and new key for a short overlap window so in-flight signed requests don't fail during the switch.
Why Do Signature Checks Fail, and How Do You Debug Them?
Nearly every "the signature is wrong" bug traces back to the same handful of causes. Re-serializing a parsed body back into a JSON string is the single most common one; whitespace, key order, and number formatting can all shift during re-encoding, which changes the bytes without changing the meaning. Fix it by hashing the untouched raw body, always.
Clock skew causes intermittent failures that look random until you check server time against NTP. Encoding mismatches (expecting hex but getting base64, or missing a sha256= prefix in your comparison string) show up as consistent, total failures rather than occasional ones. And a wrong secret, often a stale value left in an environment variable after rotation, fails everything uniformly. When debugging, log whether verification passed, a hash of the raw body, and the header names present. Never log the secret or the full raw header value.

What Else Belongs in a Defense-in-Depth Webhook Setup?
Signature verification is necessary, but it's not the whole job. OWASP's webhook security guidance frames it as one layer among several that should work together.
- HMAC-SHA256 over the raw body, checked with a constant-time comparison, is the baseline.
- Store each webhook's secret in a proper secrets manager, scoped per integration, and rotate on a schedule.
- Rate-limit the endpoint at the edge, since signature checks still consume CPU on every request and an attacker can flood the verifier without ever having a valid secret.
- Validate payload schema after signature checks pass, and design handlers to be idempotent so a duplicate delivery never causes duplicate side effects.
- Consider provider IP allowlisting as a secondary filter, and monitor rejection rates so a spike gets flagged before it becomes an incident.
How Should You Test and Monitor Webhook Verification?
Most providers offer signed test payloads or a CLI tool that lets you replay a real event against your endpoint; use that to confirm your verifier against known-good data before trusting it in production. Beyond that, write unit tests that compute HMAC against fixed test vectors (a known secret, known payload, known expected signature) so a future refactor can't silently break the comparison logic.
In production, track your invalid-signature rate as a first-class metric. A sudden spike usually means one of three things: a secret rotation went wrong, a provider changed their format, or someone is probing your endpoint. Alert on it, and always rotate secrets with an overlap window rather than a hard cutover.
What I've Learned Building Verification Into Payment Flows
Canonicalization and logging cause the most production failures I've seen, not weak cryptography. A parser touching the body before the raw bytes get captured, or a debug log that captures a header nobody meant to keep, will undo a correct HMAC implementation every time. That lesson holds directly for payment verification webhooks, where a single mishandled payload field can misroute a transaction rather than just fail a test. If you're building payment event handling, it's worth reading how secure payment pipelines treat verification as one layer among several, not the whole defense.
— David
Where Vopify Fits Into a Signed Payment Webhook Pipeline
Signature verification confirms a payment event came from the provider and wasn't tampered with in transit. It says nothing about whether the account on the other end of that payment actually belongs to the person or business you think it does. That's a separate risk, and it's the one Vopify handles.

Payment processors and banking platforms routinely push transaction and payout events through signed webhooks, and once you've verified that a payout event is genuine, the next question is whether the payee details inside it are correct. Vopify checks a name against an IBAN in real time across 20 Eurozone countries, plus coverage in India, Indonesia, South Korea, and China, so a verified webhook doesn't end up authorizing a payment to the wrong account. It works alongside your existing signature checks rather than replacing them: one layer proves the message is real, the other proves the destination is right. Finance and engineering teams building payout automation can review the Verification of Payee service or the IBAN verification overview to see how payee checks slot into a webhook-driven payment pipeline, and get started with a quick account match to see the response time firsthand.
Sources
For primary references on the mechanics covered here, see the OWASP Webhook Security Guidelines, the Node.js crypto documentation, the HMAC and SHA-2 technical overviews, and this detailed breakdown of webhook defense-in-depth practices. API security context for regulated industries is covered in this insurance API integration guide.
