DEV Community

EvvyTools
EvvyTools

Posted on

How to Generate an HMAC-SHA256 Signature to Test Your Webhook Handler

You've written a webhook handler that checks an incoming signature before processing the payload. Before you wire it up to a real provider and wait for a live event to fire, it's worth confirming the verification logic actually works, in both directions, using a payload and secret you control completely. Here's the exact process for generating a test signature by hand.

A terminal window running a command line script, representing generating a test signature by hand
Photo by Bibek ghosh on Pexels

Step 1: Pick a Fixed Test Payload and Secret

Use a small, realistic JSON payload, something close to what your actual webhook provider sends, and a throwaway secret key you're not using anywhere real. Keep both fixed for the duration of testing so you can reliably reproduce the same signature across multiple test runs, which matters once you start comparing output from more than one tool or script. Something like this works fine:

payload: {"event":"order.completed","order_id":"test_123","amount":4200}
secret: test_secret_do_not_use_in_prod
Enter fullscreen mode Exit fullscreen mode

Keep the payload realistic rather than trivial. A single-field test object might pass your checks while missing an encoding issue that only shows up with nested objects, arrays, or non-ASCII characters, all of which are common in real webhook payloads from providers with international customers.

Step 2: Generate the HMAC-SHA256 Signature

HMAC-SHA256 takes your payload and secret and produces a fixed-length hex string. You can do this with a one-liner in most languages:

import hmac, hashlib
signature = hmac.new(
    b"test_secret_do_not_use_in_prod",
    b'{"event":"order.completed","order_id":"test_123","amount":4200}',
    hashlib.sha256
).hexdigest()
Enter fullscreen mode Exit fullscreen mode

If you'd rather not spin up a script just to sanity-check a signature, EvvyTools' Hash Generator has an HMAC-SHA256 mode built for exactly this: paste the payload and the secret key in, get the signature back instantly, and confirm it matches what your code produces before you trust either implementation. This is especially useful the first time you're setting up a new language or framework's crypto library, since it gives you a fast way to confirm the library itself is producing correct output before you build any real logic on top of it.

Step 3: Send the Payload With the Signature Header Attached

Whatever header name your webhook provider uses (X-Signature, Stripe-Signature, X-Hub-Signature-256, the naming varies by provider but the mechanism is the same), attach your generated signature to a test request and send it to your endpoint using curl or a tool like Postman:

curl -X POST https://yourapp.com/webhooks/test \
  -H "Content-Type: application/json" \
  -H "X-Signature: sha256=<your generated signature>" \
  -d '{"event":"order.completed","order_id":"test_123","amount":4200}'
Enter fullscreen mode Exit fullscreen mode

Confirm your handler accepts this and processes it as a valid, correctly signed request. If it doesn't, check the raw body your server is actually receiving against what you signed, since a middleware layer that reformats or re-parses the request before your handler sees it is a common source of mismatches at this step.

Step 4: Confirm the Rejection Path Actually Works

This is the step most implementations skip, and it's the one that actually matters. Take the exact same payload, change a single character in it (bump the amount by a dollar, for instance), and resend it with the original, now-mismatched signature still attached. Your handler should reject this request. If it doesn't, your signature check isn't actually doing anything, it's just present in the code without functioning as a gate.

A surprising number of "working" webhook integrations have never actually had this negative case tested. The happy path gets exercised constantly in normal operation, every real event that arrives confirms the accept path works. The rejection path only gets exercised by an actual attacker, which is exactly the wrong time to discover it doesn't work.

Step 5: Test the Timestamp Window if Your Provider Uses One

Some webhook providers sign a timestamp along with the payload and expect your handler to reject requests outside a short tolerance window, to prevent replay attacks. If your provider does this, generate a signature using an old timestamp (an hour in the past, say) and confirm your handler rejects it even though the signature itself is technically valid for that stale timestamp. This catches a specific and common bug: implementations that verify the signature correctly but forget to check the timestamp at all, which leaves the door open to replaying a captured request indefinitely.

Step 6: Automate the Whole Sequence Once It Works Manually

Once you've confirmed all of the above by hand, it's worth wrapping the test payload, secret, valid case, tampered case, and stale-timestamp case into an actual automated test in your codebase, rather than relying on remembering to re-run the manual checks after every change to the handler. A regression here is easy to introduce accidentally, a refactor that moves body parsing earlier in the middleware chain, for instance, and an automated test catches it immediately instead of waiting for a production incident to surface it.

Debugging When Your Signature Doesn't Match

If your manually generated signature doesn't match what your handler computes, the mismatch is almost always one of a small set of causes. Check whether your handler is verifying against the raw request body or a re-parsed version of it, since even a single reformatted whitespace character changes the HMAC output completely. Check whether the secret key has any trailing whitespace or newline character accidentally included, which is a surprisingly common copy-paste mistake when pulling a secret from an environment variable or a configuration file. Check that both sides agree on text encoding, UTF-8 almost always, but worth confirming explicitly rather than assuming, especially if either side is doing any kind of implicit string-to-bytes conversion under the hood. And check that you're comparing hex-encoded output to hex-encoded output, not accidentally comparing a hex string against a base64-encoded one, since some libraries default to one encoding and some default to the other, and a signature computed correctly in the wrong encoding will never match no matter how many times you regenerate it.

Working through this list in order, rather than guessing randomly, turns a frustrating debugging session into a five-minute checklist. Nearly every "my HMAC doesn't match" question that comes up in developer forums traces back to one of these four causes.

Why This Matters More Than It Might Seem

A signature check that's never been tested against a deliberately wrong input is a check you're assuming works, not one you've confirmed works. The underlying reason HMAC exists at all, rather than a simpler plain hash, is covered in more depth in Why Webhook Signature Verification Uses HMAC Instead of a Plain Hash, including why a hash alone can't prove who sent a request, only that it wasn't altered.

A Note on Test Secrets

It's worth repeating even though it sounds obvious: never reuse a real production secret for manual testing in a browser tool, a shared document, or a script you might accidentally commit. Generate a throwaway test secret specifically for this kind of debugging, and if your webhook provider supports separate test-mode and live-mode credentials, use the test-mode secret for all of the steps above rather than the one protecting real traffic. This keeps a debugging session from turning into an accidental credential leak, which is a much more expensive mistake to clean up than the couple of minutes it takes to generate a dedicated test secret up front.

For more background on the HMAC construction, the IETF's RFC 2104 is the original specification. The Postman documentation covers setting up and saving test requests like the curl example above if you'd rather build a reusable test collection than run one-off commands. EvvyTools has a full set of free developer utilities beyond the hash generator referenced here if you're setting up a broader testing workflow.

Top comments (0)