DEV Community

Haven Messenger
Haven Messenger

Posted on Originally published at havenmessenger.com

Verifying Webhooks: The Four Checks Handlers Skip

A webhook endpoint is an unauthenticated POST route on the public internet that writes to your database, sends email, moves money or provisions accounts. There is no session, no bearer token from your own identity provider and no user to consent. The signature header is the entire authentication story.

Most implementations verify that header, and most of them still leave at least one of four gaps open. The gaps are consistent enough across codebases that they are worth naming individually.

What a signature actually asserts

The common construction is an HMAC over a canonical string, keyed with a secret that both sides hold, delivered as hex or base64 in a header. The canonical string is usually the request body, sometimes prefixed with a timestamp and a version marker.

What a valid HMAC proves is that someone holding the secret produced these exact bytes at some point. It does not prove when, it does not prove that this is the first time you have seen them, and it does not prove which of the several parties holding the secret produced them. Each of the checks below closes one of those gaps.

Check one: sign the raw bytes

The single most common defect is computing the HMAC over a re-serialised body. A handler receives the request, the framework parses JSON into a dictionary, the verification code serialises that dictionary back to a string, and the HMAC is computed over the result.

Those bytes are not the bytes that were signed. Key order changes. Unicode escaping differs between libraries. Floating point formatting differs. Insignificant whitespace disappears. Sometimes the round trip happens to produce identical output and the code works in testing, then fails on the first payload containing a non-ASCII character or a number that formats differently.

The fix is to capture the raw body before any parsing and verify against it. In most frameworks that requires explicit configuration, because the default middleware parses first and discards the original buffer. Do that work before writing the comparison, because a verification that passes on re-serialised bytes is not verifying the same message the sender signed.

Check two: bound the replay window

A signature with no timestamp is valid forever. Anyone who captures one valid request, from a proxy log, an error tracker, a misconfigured mirror or a compromised staging environment, can replay it at any time and it will verify correctly.

Providers that handle this include a timestamp in the signed string, typically as a header the receiver reads and folds into the canonical form. The receiver rejects anything outside a tolerance window, commonly a few minutes. Two details matter and are frequently skipped:

  • The timestamp must be inside the signature. A timestamp header that is not part of the signed string can be edited freely by whoever is replaying the request.
  • The window still permits replay inside it. Keep a short-lived cache of event identifiers already processed and reject repeats. Five minutes is plenty of time to send the same payment-succeeded event forty times.

Check three: compare in constant time

Comparing the computed digest to the received one with an ordinary string equality operator returns as soon as it finds a differing byte. The time taken therefore depends on how many leading bytes matched, which leaks information about the correct value.

Remote timing attacks across the internet are difficult and noisy, and this is the check people most often argue about skipping. The argument is not worth having, because the fix is a single function call that already exists in every standard library: hmac.compare_digest in Python, crypto.timingSafeEqual in Node, subtle.ConstantTimeCompare in Go. See constant-time programming for why the property is harder to preserve than it looks.

Check four: close the fallback paths

The first three checks are code. This one is process, and it is where verified endpoints quietly become unverified.

  • Accept-if-missing. Added during a migration so the old sender keeps working, never removed. An attacker simply omits the header.
  • Permanent dual-secret acceptance. Rotation implemented as accept-either, with no expiry on the old secret. The compromised secret stays valid indefinitely.
  • Secrets in the URL. A per-endpoint token in the query string appears in proxy logs, browser referrers and error reports, and cannot be rotated without redeploying the sender.
  • The unverified twin. A debug or test endpoint that accepts the same payloads without checking, left routable in production.
  • Returning 200 on verification failure. Done to stop the provider retrying, it also stops anyone noticing that forged requests are arriving.
Check If skipped Fix
Raw body Verification is unreliable and breaks on encoding differences Capture the buffer before parsing
Signed timestamp Captured requests replay forever Include the timestamp in the signed string, reject outside tolerance
Event deduplication Repeats inside the tolerance window are processed again Short-lived cache of processed event identifiers
Constant-time compare Byte-by-byte comparison timing leaks the expected digest Use the standard library comparison function
No fallback path The strongest verification is bypassed rather than broken Remove accept-if-missing, expire old secrets, delete debug twins

After the signature verifies

Verification is the start of the handler, not the end of the security work.

Providers retry on timeout, so the same valid event will arrive more than once as a matter of normal operation. Handlers need idempotency for correctness regardless of any attack. Do the verification and the deduplication before any expensive work, and acknowledge quickly, because a slow handler produces retries that look like an attack.

Webhook bodies frequently contain personal data, and the signature header is a credential-adjacent value. Neither belongs in an unredacted request log or an error tracker payload. If your service lets a customer configure the URL a webhook is sent to, that outbound request is a server-side request forgery surface, and the destination needs validating against internal address ranges before the first send.

Symmetric versus asymmetric signing

With HMAC, both parties hold the same secret, so a receiver's leaked secret lets an attacker forge messages that appear to come from the sender. Providers that sign with Ed25519 or ECDSA give receivers a public verification key instead, and a leaked verification key forges nothing. If a provider offers asymmetric signing, take it. It removes an entire class of incident from your side of the boundary.

Testing that the verification fires

A test suite that only sends correctly signed payloads proves nothing, because a handler that accepts everything passes it. The tests that carry information are the negative ones: a request with the signature header removed, a request whose body was modified by one byte after signing, a request signed with the previous secret after rotation, a replayed request with a stale timestamp, and a replayed request with a fresh timestamp and a repeated event identifier.

Each of those should be rejected with a distinct log line. If they are not tested, nothing proves the verification still fires, and security code has a way of quietly stopping during a refactor that nobody flagged.

Originally published at havenmessenger.com

Top comments (0)