Back to blog
Article

Webhook Signing Done Right: HMAC, Timestamps, and Replay Attacks

By Aylon··6 min read

Most webhook signing implementations have at least one of three classic bugs. Here's the production pattern that avoids all of them.

Signing webhooks lets the receiver trust that an event actually came from your service. Done right, the receiver verifies in a few lines of code. Done wrong, you have one of three classic security bugs: a timing-attack-vulnerable comparison, no replay protection, or rotation that breaks in-flight deliveries.

What signing solves

The receiver gets a POST from somewhere on the internet claiming to be your service. They need to know two things:

  1. Did this actually come from us (authenticity)?
  2. Is this a recent request, not something an attacker replayed (freshness)?

Without signing, either can be spoofed. Anyone who knows the receiver's URL can forge events. Anyone who captured a real event from the network or from the receiver's logs can replay it indefinitely.

The pattern that works

The pattern is HMAC-SHA256 over (timestamp + body), with the timestamp sent alongside the signature:

X-Pushrail-Timestamp: 1748540468
X-Pushrail-Signature: t=1748540468,v1=HMAC_SHA256(secret, timestamp + "." + body)

The receiver verifies by:

  1. Extracting the timestamp and signature from headers.
  2. Checking the timestamp is within an acceptable window (5 minutes is typical).
  3. Recomputing HMAC-SHA256(secret, timestamp + "." + body) using their stored secret.
  4. Comparing the two signatures with a timing-safe equality check.

If both the timestamp window and the signature check pass, the event is trusted.

The three classic bugs

Bug one: non-timing-safe comparison.

if computed_signature == provided_signature:  # WRONG

Standard equality compares byte-by-byte and returns false on the first mismatch. An attacker timing many requests can deduce the signature one byte at a time. The fix is constant-time comparison:

import hmac
if hmac.compare_digest(computed_signature, provided_signature):  # CORRECT

Bug two: no replay window.

If the signature alone is checked, an attacker who captures one valid request can replay it indefinitely, same body, same signature, same outcome. The fix is the timestamp:

  • The timestamp is included in the signature computation, so it can't be modified without invalidating the signature.
  • The receiver rejects events with timestamps more than ~5 minutes old.

That makes a replayed event useless after 5 minutes. The window can be tuned: shorter is safer but more sensitive to clock skew; longer is more permissive.

Bug three: rotation that breaks in-flight deliveries.

When the customer rotates their signing secret, deliveries currently in flight (about to be retried, sitting in the queue) were signed with the old secret. If the platform immediately revokes the old secret, those in-flight deliveries fail signature verification on the receiver's side, and customers think the rotation broke their integration.

The fix is dual-secret support during rotation:

  • When a new secret is added, the platform accepts both old and new for signing.
  • New deliveries use the new secret; in-flight deliveries continue with the secret they were signed under.
  • After a drain window (typically 1 hour, covering the retry window), the old secret is revoked.

The receiver, conversely, should be willing to verify with multiple known-good secrets during the rotation window so they can verify both old and new in parallel.

What gets signed

The signature covers the timestamp and the raw body. Things that should NOT be part of the signature:

  • URL paths (URLs can be rewritten by proxies)
  • Headers (proxies add and modify them)
  • Encoding metadata (chunked transfer, content-encoding)

If anything but the timestamp and the canonical body is part of the signature, a normal infrastructure transformation breaks verification. Keep the signed envelope minimal.

What about HTTPS?

TLS gives transport-level confidentiality and integrity. It doesn't give application-level authenticity. The receiver knows the TLS connection wasn't tampered with, but they don't know if the request came from your service or from some other compromised system that knows the URL.

HMAC signing answers "did this come from a holder of the secret?" which is the question the receiver actually has.

Building it vs using it

The signing logic itself is small, a few dozen lines per language. What takes longer is everything around it: secret storage with encryption at rest, secret generation with adequate entropy, dual-secret support during rotation, header documentation, verification examples in each language the customers use, and the inevitable support tickets about clock skew breaking verification.

Pushrail handles all of this per webhook destination, see the webhooks destination page for header format and verification snippets in the languages our customers use.

Next: outbound webhook architecture, putting all the Reliability-cluster pieces together into a reference architecture.

Ready to stop building delivery infrastructure?

Start free. Send your first event in under 5 minutes.

Protected by reCAPTCHA, Google's Privacy Policy and Terms apply.