Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Testing Webhook Signature Verification

Quick answer: A webhook signature is an HMAC computed over the raw request body with a shared secret, sent in a header the receiver checks before doing any work. Test it with a fixed set of cases: a valid signature accep

Quick answer: A webhook signature is an HMAC computed over the raw request body with a shared secret, sent in a header the receiver checks before doing any work. Test it with a fixed set of cases: a valid signature accepted, a tampered body rejected, a wrong secret rejected, a missing or malformed header rejected, and a stale timestamp rejected as a replay. On every rejection the receiver should return 401 and skip the side effect, and the signature comparison should run in constant time. The signature must be computed on the original bytes, not on JSON re-serialized by the framework, since reordering fields changes the digest.

How the signature is built

The sender concatenates a timestamp and the raw body, hashes the result with the shared secret, and puts the digest in a header, often in a form such as t=1700000000,v1=.... The receiver rebuilds the same digest from the bytes it received and compares it against the header. Because the secret never travels in the request, an attacker who cannot produce the digest cannot forge a call, which is why unsigned or badly signed requests must be dropped before the handler runs. Pin the exact scheme and header format your provider uses rather than assuming a generic shape.

The test matrix

Case Signature header Expected Side effect
Valid Correct digest, fresh timestamp 200 Handler runs once
Tampered body Digest from the original body 401 None
Wrong secret Digest built with another key 401 None
Missing header Absent or empty 401 None
Stale timestamp Valid digest, old timestamp 401 None
Rotated secret New v1 alongside old v0 200 Handler runs once

The tampered-body case is the one that matters most: keep the signature from the original payload while changing one byte, so the digest no longer matches. The rotated-secret case covers key rollover, where the receiver accepts more than one version for a window.

Use the raw body, not re-serialized JSON

A common failure is verifying the signature against a parsed and re-serialized object instead of the original bytes. JSON allows different field order and whitespace, so the same logical object re-serialized by the framework produces a different digest and rejects genuine calls, or worse, a loose parser accepts a body the signature never covered. Capture the raw request buffer and run the HMAC over those exact bytes, then parse the body only after the check passes. The test should include a payload with reordered or extra whitespace to confirm the receiver hashes what actually arrived.

Constant-time comparison and replay window

Do not compare digests with a plain equality check that exits on the first differing byte, since the response time leaks how many leading bytes matched. Use a constant-time comparison provided by the platform so every signature takes the same path regardless of how close it is. The timestamp gives a separate replay control: define a tolerance window, reject signatures outside it even if the digest is valid, and confirm the test for an old timestamp returns 401. In a desktop API workspace you can run the whole matrix as an ordered scenario that builds each header variant and asserts the receiver status and recorded side effects. Powerduck works this way, turning each signature branch into a repeatable request instead of a handcrafted curl.

Honest limits

These tests prove the receiver follows the verification logic you wrote, but they do not prove the shared secret is stored safely, rotated in production, or protected from logs and crash dumps. A local mock can stand in for the provider, but it cannot confirm the real provider's header scheme, clock skew, or retry behavior, so pin the scheme against genuine documentation and send a small number of real webhooks. The checks do not cover TLS termination, network-level attackers, or a secret that has already leaked, and a passing signature says nothing about whether the payload content is correct. Importing a schema does not deploy a backend, and a passing run is not evidence about the upstream service.

Frequently asked questions

Why verify the raw body instead of parsed JSON? Parsing and re-serializing can change field order and whitespace, which changes the digest. The HMAC has to match the exact bytes the sender signed.

What does the timestamp protect against? It limits replay. A captured request with a valid signature is rejected once the timestamp falls outside the tolerance window.

Is a 401 the only rejection response? Most receivers use 401, but pin the status your framework returns. The key assertion is that no handler side effect runs on any rejected branch.

Why use constant-time comparison? A normal string comparison returns early on the first mismatch, leaking timing information. A constant-time check avoids that signal.

The bottom line

Treat webhook verification as a set of explicit branches and run the valid, tampered, wrong-secret, missing-header, stale-timestamp, and key-rotation cases, hashing the raw bytes and comparing in constant time. Assert that every rejection returns 401 and performs no side effect, and confirm the real provider scheme separately. For more on this approach, see the API testing guide hub.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.