Webhook Signature Verification Tester: Stripe, GitHub, Shopify, and Generic HMAC

A webhook integration works perfectly in testing, ships to production, and then starts silently rejecting every real event with a signature mismatch error, and there’s rarely a clear reason why in the error message itself. The payload looks identical. The secret is definitely correct, copy-pasted straight from the provider’s dashboard. And yet the computed signature never matches what the provider sent, usually because of something subtle in exactly how the raw request body was captured, re-serialized, or modified somewhere between the network and the code doing the actual verification.

This tool computes the expected HMAC signature for Stripe, GitHub, Shopify, or a generic webhook setup directly in your browser, so you can compare it against what you’re actually receiving and figure out exactly where the mismatch is coming from. Nothing you enter here, including your secret, is sent anywhere; the computation happens entirely client-side using the browser’s built-in cryptography API.

Compute an Expected Signature

Computed entirely in your browser. Nothing you enter, including the secret, leaves this page.
Signed payload (what’s actually being hashed)
Computed expected signature
Enter a secret to compute
Paste a received signature above to compare.

How Webhook Signature Verification Actually Works

A webhook signature exists to answer one specific question: did this request genuinely come from the provider it claims to be from, or could anyone who knows your endpoint URL send a fake event and have your application act on it. Without verification, a webhook endpoint accepting a “payment succeeded” event with no proof of origin is trivially abusable, since anyone with the URL could POST a fabricated success event and potentially trigger whatever your application does in response, like fulfilling an order that was never actually paid for.

The mechanism is HMAC, a keyed hash function that combines the request body with a shared secret, known only to the provider and to you, to produce a signature that can’t be forged without knowing that secret. When a webhook arrives, your server recomputes the same HMAC using the same secret and the received body, and compares the result against the signature the provider included in the request headers. If they match, the request genuinely came from someone holding the correct secret, which in practice means it came from the real provider, since the secret should never be shared or exposed anywhere else.

Why the Exact Raw Body Matters So Much

This is where the overwhelming majority of real-world signature mismatches actually come from, and it’s rarely obvious from the error alone. HMAC is computed over the exact bytes of the request body as it was originally sent, not a logically equivalent representation of the same data. If your framework automatically parses the incoming JSON body before your webhook handler ever sees it, and you then re-serialize that parsed object back into a string to verify the signature, the re-serialized JSON can differ from the original in ways that seem irrelevant but completely change the computed hash: different key ordering, different whitespace, a trailing newline present in one version and not the other, numbers formatted slightly differently.

The fix is almost always: capture the raw body before any parsing happens

Nearly every popular web framework has a way to access the request body as raw, unparsed bytes before JSON parsing middleware touches it, and webhook signature verification needs to happen against that raw form specifically, not a JSON.parse’d and re-stringified version of it. This is the single most common root cause behind “the secret is definitely right but the signature still doesn’t match” reports.

Stripe’s Specific Format

Stripe includes a Stripe-Signature header formatted as t=1700000000,v1=abc123..., where t is a Unix timestamp and v1 is the actual signature. The signed payload isn’t just the raw body on its own, it’s the timestamp concatenated with a period and then the raw body: {timestamp}.{raw_body}. This detail is easy to miss if you’re computing a signature over the body alone and wondering why it never matches Stripe’s value.

The timestamp’s inclusion serves a specific security purpose beyond just signing: Stripe’s own verification libraries reject a webhook if the timestamp is too far in the past, typically outside a five-minute tolerance window, which mitigates replay attacks where someone captures a legitimate, correctly-signed webhook and resends it later. If you’re implementing verification manually rather than through Stripe’s SDK, replicating this timestamp tolerance check is worth doing deliberately, not just verifying the signature itself.

GitHub’s Specific Format

GitHub sends a X-Hub-Signature-256 header formatted as sha256= followed by the hex-encoded HMAC-SHA256 digest of the raw request body directly, no timestamp concatenation involved. This is a simpler format than Stripe’s, but the raw-body caveat applies just as strongly, and GitHub’s own documentation specifically warns that middleware parsing the body before signature verification is a common source of failed verification for exactly this reason.

Shopify’s Specific Format

Shopify sends an X-Shopify-Hmac-Sha256 header containing the HMAC-SHA256 digest of the raw body, but encoded as base64 rather than hex, which is a detail that trips up anyone assuming every provider uses the same encoding. Comparing a hex-encoded computed signature against Shopify’s base64-encoded header will never match regardless of whether the secret and body are otherwise completely correct, and this specific hex-versus-base64 mismatch is worth checking first if a Shopify integration in particular seems to be failing consistently despite everything else appearing right.

Building Your Own Webhook Sender

If you’re building a system that sends webhooks to other applications rather than receiving them, the same HMAC pattern applies in reverse, and a few decisions are worth making deliberately rather than copying whichever provider’s format happens to be top of mind. Including a timestamp in the signed payload, the way Stripe does, is worth adopting even for a simple internal system, since it gives receivers a straightforward way to reject old, replayed requests rather than trusting a signature’s validity indefinitely.

Documenting the exact signed-payload format explicitly and unambiguously for whoever integrates with your webhooks matters more than it might seem, given how much of this entire debugging category traces back to a mismatch between what the sender actually signs and what the receiver assumes it’s supposed to verify. A one-line code example showing exactly how to reconstruct the signed string removes an entire category of integration support requests before they happen.

Timing-Safe Comparison: The Security Detail Most Implementations Miss

Once you’ve computed the expected signature, comparing it against the received one using a standard string equality check, like === in JavaScript or == in PHP, introduces a subtle timing vulnerability. Standard string comparison typically returns as soon as it finds the first mismatched character, meaning a correct guess produces a measurably longer comparison time than an incorrect one, and an attacker capable of making many requests and measuring response time precisely could, in theory, use those tiny timing differences to guess a valid signature one character at a time.

This is a genuinely subtle, low-probability attack in most real-world contexts, but it’s cheap to close off entirely, which is exactly why security guidance consistently recommends doing it anyway. Node’s crypto module provides crypto.timingSafeEqual(), and PHP provides hash_equals(), both performing a constant-time comparison that takes the same amount of time regardless of where or whether a mismatch occurs, removing the timing side-channel entirely rather than relying on the comparison being fast enough that nobody could realistically exploit it.

// Node.js
const crypto = require('crypto');
const expected = Buffer.from(computedSignature, 'hex');
const received = Buffer.from(receivedSignature, 'hex');
const isValid = expected.length === received.length &&
  crypto.timingSafeEqual(expected, received);

// PHP
$isValid = hash_equals($computedSignature, $receivedSignature);

Common Mistakes When Debugging Signature Mismatches

Verifying against a parsed-and-re-serialized body instead of the true raw bytes, covered above, is by far the most frequent cause, and it’s worth checking first whenever a signature that should be correct isn’t matching.

Using the wrong secret is the second most common cause, and it’s more common than it sounds specifically because most providers issue separate signing secrets for test mode and live mode, or for different individual webhook endpoints configured in the same account. A secret that’s correct for one environment or endpoint will produce a completely different, seemingly “wrong” signature when used against events from a different one, even though nothing about the verification code itself is broken.

Encoding mismatches, expecting hex when a provider sends base64 or vice versa, produce signatures that will never match no matter how many times the computation is retried, since the underlying bytes might be identical while their string representation looks completely different. This is exactly the Shopify-specific gotcha described earlier, but it’s worth checking as a general category whenever integrating with any new, unfamiliar provider for the first time.

And forgetting that some providers, Stripe specifically, sign a composed string rather than the raw body alone, leads to a correctly-implemented HMAC computation that’s simply hashing the wrong input entirely. The math is right, the secret is right, but the signed payload itself doesn’t match what the provider actually signed.

Framework-Specific Notes on Accessing the Raw Body

Express (Node.js)

The default express.json() middleware parses the body before your route handler runs, which removes access to the original raw bytes by the time your webhook handler executes. The standard fix is using express.raw({ type: 'application/json' }) specifically on the webhook route, which gives you the untouched raw body as a Buffer, letting you compute the signature correctly before manually parsing it as JSON afterward for your own application logic.

Laravel

Laravel’s default middleware stack also parses incoming JSON automatically. Accessing the true raw body for signature verification typically means reading it directly from the request before Laravel’s usual body-parsing takes effect, commonly through $request->getContent(), which returns the raw request body as a string rather than the already-decoded array Laravel’s normal request handling provides.

FAQ

Why does my signature never match even though the secret is definitely correct?

The most common cause by far is verifying against a re-serialized version of the body rather than the exact raw bytes originally received. Check whether any middleware is parsing the JSON body before your signature verification code runs.

Is it safe to compute a webhook signature in a browser-based tool?

For testing and debugging with a test-mode secret, yes, since the computation happens entirely client-side and nothing is transmitted anywhere. Avoid pasting a live, production signing secret into any tool, including this one, as a general precaution, and use a test-mode secret for debugging whenever a provider offers one.

Why does Stripe include a timestamp in the signed payload?

To let receivers reject old, replayed webhook requests. Without a timestamp, a captured, validly-signed webhook could be resent indefinitely and would still pass signature verification, since the signature itself never expires on its own.

Does using === to compare signatures actually matter in practice?

It closes off a genuine, if narrow, timing-based attack vector, and since a constant-time comparison function is a one-line change in most languages, there’s little reason not to use it. It’s a small, low-cost fix for a real, documented class of vulnerability.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top