DEV Community

Cover image for What a successful HMAC check tells you about webhook forwarding
Yuriy for Adal

Posted on • Originally published at adal.cloud

What a successful HMAC check tells you about webhook forwarding

With payment webhooks, “almost unchanged” is not unchanged enough.

A real payment webhook passed through an intermediary delivery service and still verified at the final handler. Here is what that proves—and what it does not.

Recently, a payment webhook in one of my production systems followed this path:

Payment provider → Adal Server → Adal CLI → webhook handler
Enter fullscreen mode Exit fullscreen mode

The handler verified the provider's HMAC only after the request had completed that entire journey. The verification succeeded.

That result may sound routine, but it is a useful end-to-end test of any system that sits between a webhook producer and its final receiver.

Why raw request data matters

The exact signature scheme varies by provider, but the basic pattern often looks like this:

raw_body = read_request_body_as_bytes()
signed_message = build_message_according_to_provider_rules(raw_body, timestamp)
expected_signature = HMAC(secret, signed_message)
valid = constant_time_compare(expected_signature, received_signature)
Enter fullscreen mode Exit fullscreen mode

The important part is that the receiver usually needs the original request body as bytes—not a parsed representation of the payload.

Suppose an intermediary receives this JSON:

{"event":"payment.completed","amount":4200}
Enter fullscreen mode Exit fullscreen mode

It parses the body and serializes it again as:

{
  "event": "payment.completed",
  "amount": 4200
}
Enter fullscreen mode Exit fullscreen mode

The data means the same thing to a JSON parser, but the byte sequence is different. If the provider signed the raw body, the second version will produce a different HMAC.

The same problem can appear through changes in escaping, character encoding, line endings, or number formatting. If a timestamp or header is part of the provider's signing scheme, changing that value can invalidate the signature as well.

This is why a webhook intermediary must not treat payload preservation as a cosmetic concern.

What happened in this production flow

I use Adal as an intermediate delivery layer for this integration.

The public Adal Server receives the payment webhook and stores the Request. The Adal CLI then delivers it to a handler running inside a private environment. This gives me a stable public webhook URL, request visibility, delivery history, and the ability to retry a failed delivery without exposing an inbound port on the destination machine.

The HMAC is not verified by Adal in this workflow. It is verified by the final application, where the payment event is actually processed.

So the complete sequence is:

receive → store → send through the CLI → deliver → verify HMAC
Enter fullscreen mode Exit fullscreen mode

The final verification succeeds. That means the values covered by the payment provider's signing scheme survived the delivery path without a signature-breaking transformation.

In practical terms, the JSON body was not parsed and rebuilt, reformatted, or otherwise rewritten between the provider and the handler.

What this proves—and what it does not

It is tempting to summarize the result as “the entire HTTP request arrived byte for byte unchanged.” That would be broader than the evidence supports.

HMAC verification confirms the integrity of the values included in the provider's signed message. Depending on the provider, that might be:

  • the raw request body;
  • a timestamp and the raw body;
  • selected request metadata;
  • some other provider-specific canonical representation.

It does not necessarily cover every HTTP header or transport detail. A request forwarded over a new HTTP connection may receive a different Host, Content-Length, User-Agent, or connection-specific header. Those differences do not matter unless the provider includes the affected value in its signing scheme.

So the precise conclusion is:

A successful HMAC check at the final handler is strong practical evidence that the signed data was preserved across the forwarding path.

That is already a meaningful guarantee. There is no need to overstate it.

A checklist for forwarding signed webhooks

If you place a proxy, queue, gateway, tunnel, or delivery service between a provider and your application, I would verify the following:

  1. Read the provider's signing specification. Identify exactly which bytes and metadata are signed.
  2. Capture the raw body before parsing it. Many web frameworks parse JSON automatically unless configured otherwise.
  3. Forward every value used in the signature. This may include a timestamp or provider-specific signature header.
  4. Do not deserialize and reserialize the payload. Equivalent JSON is not necessarily an equivalent signed message.
  5. Use a constant-time comparison. Avoid comparing authentication values with an ordinary string equality operation.
  6. Verify at the final trust boundary. The application making the business decision should perform the signature check.
  7. Test the complete route. A unit test for your HMAC helper does not exercise storage, forwarding, framework middleware, and delivery together.
  8. Use a real provider event when possible. It confirms that your assumptions match the provider's actual implementation.

The seventh point is the one this production case reinforced for me. Unit tests are necessary, but a real signed webhook reaching the final handler exercises much more of the system.

The best intermediary is a boring one

Webhook infrastructure can add useful behavior around a request: persistence, retries, observability, routing, and delivery into private environments.

What it should not add is uncertainty about the payload your application receives.

In this case, the result was deliberately uneventful: the payment provider created and signed a webhook, Adal received and delivered it, and the destination verified the signature successfully.

For webhook infrastructure, boring is a feature.

The original version of this article was published on the Adal blog.

Top comments (0)