Webhook HMAC Signatures Need Replay Controls

A webhook receiver often needs to decide whether an HTTP request came from a configured sender and whether the payload changed in transit. A keyed message authentication code can support that decision when both sides share a secret and compute the tag over the same bytes.

That property does not make a captured request single-use. If an attacker records a valid request and submits the same authenticated material again, the tag can remain valid. Replay resistance therefore has to be part of the webhook protocol around the MAC, not an assumption attached to the MAC itself.

The signed input must be defined exactly

HMAC, specified in RFC 2104, combines a cryptographic hash with a secret key to produce a message authentication code. The receiver recomputes the tag and compares it with the supplied value.

For a webhook, the protocol must define the authenticated input without ambiguity. A simple construction can bind a timestamp to the raw request body:

signed_input = timestamp || "." || raw_body
tag = HMAC-SHA-256(secret, signed_input)

The delimiter and encoding are protocol details. Both endpoints must use the same representation.

Signing parsed JSON is a different protocol. Parsing and serializing JSON can alter whitespace, member ordering, escaping, or numeric representation. A receiver that verifies the raw body should retain those bytes until authentication has completed.

The same rule applies to any additional request component. If a method, path, content type, or delivery identifier is security-relevant, its canonical representation has to be included deliberately rather than assumed to be covered.

A valid MAC does not establish freshness

Consider one legitimate delivery:

POST /hooks/payment
timestamp: 1789912800
body: {"event":"invoice.paid","id":"evt_42"}
tag: <valid HMAC>

An observer who can capture the complete request may not know the secret and may be unable to forge a different body. The observer can still resend the captured timestamp, body, and tag.

The cryptographic verification can succeed because the authenticated bytes have not changed. The missing property is freshness.

A timestamp gives the receiver a basis for rejecting old requests:

abs(receiver_time - signed_time) <= acceptance_window

The exact window is an operational policy. It has to accommodate expected delivery delay and clock error without becoming needlessly broad.

The timestamp itself must be authenticated. Checking an unsigned timestamp while verifying a MAC over only the body lets an attacker alter the freshness value without invalidating the tag.

A time window limits replay but does not remove it

A five-minute acceptance window, for example, can reject a captured request after five minutes. It does not stop the same request from being replayed several times during those five minutes.

Protocols that require stronger duplicate suppression need a value that identifies a delivery, such as a nonce or event identifier, and receiver-side state that records accepted values for an appropriate retention period.

Conceptually:

verify MAC
   |
   v
check signed timestamp
   |
   v
check delivery_id not seen
   |
   v
record delivery_id
   |
   v
process event

The identifier also has to be covered by the authenticated input. Otherwise an attacker could change it while retaining the authenticated body.

Retention should match the replay policy. Deleting identifiers before the relevant acceptance period ends can reopen the duplicate path that the store was meant to close.

Duplicate delivery and hostile replay can look identical

Webhook systems commonly retry deliveries after network failures or non-success responses. A receiver can therefore see the same legitimate event more than once even when no attacker is present.

Security controls and delivery semantics have to fit together. Rejecting a repeated delivery identifier at the authentication boundary is appropriate for a protocol that defines each signed delivery as single-use. In another design, the receiver may accept repeated authenticated deliveries but make event processing idempotent.

For example, a database operation can use the provider’s stable event identifier as a uniqueness key:

event_id: evt_42

first insert  -> accepted
second insert -> already processed

Idempotency protects application effects from duplicate processing. It is not a substitute for authenticating the request, and it does not by itself place a time bound on captured traffic.

Compare authentication tags without data-dependent early exit

After recomputing the expected tag, the receiver should use the platform’s constant-time comparison primitive for equal-length authentication tags rather than a normal string equality operation.

The input format should be validated before comparison. Hex and Base64 encodings need strict decoding rules, and malformed values should fail authentication rather than being normalized through several permissive representations.

Secret selection also matters during key rotation. If the protocol supports key identifiers, the identifier should select a bounded set of candidate secrets. Trying every historical secret indefinitely expands both operational complexity and the set of credentials that remain useful.

Verification belongs before side effects

The receiver should authenticate the request and apply freshness checks before parsing untrusted data more deeply than required or performing business actions.

A practical order is:

enforce request-size limit
        |
        v
read raw body
        |
        v
parse signature metadata strictly
        |
        v
select active secret
        |
        v
verify HMAC
        |
        v
check signed freshness data
        |
        v
apply duplicate policy
        |
        v
parse event and authorize action

Request-size limits remain useful because an unauthenticated sender can still consume bandwidth and memory before cryptographic verification finishes.

Authentication also does not replace application authorization. A valid sender may be permitted to emit several event types while a particular endpoint is allowed to act on only a subset. The event type, tenant binding, object identity, and requested state transition still need application-level checks.

Failure handling should not expose verification detail

Externally visible errors do not need to distinguish an unknown key identifier, malformed tag, incorrect MAC, stale timestamp, or repeated nonce. Detailed distinctions can stay in protected telemetry while the HTTP response remains intentionally coarse.

Logs should avoid recording shared secrets or full signature material. Event identifiers, bounded reason codes, sender configuration identifiers, and timing data are usually sufficient for operations without turning logs into a credential archive.

Rate limits can further reduce abuse, but they are a separate control. A rate limit does not authenticate a request, and a valid MAC does not guarantee that a sender will remain within expected traffic volume.

The security boundary is the complete verification protocol

HMAC supplies message authentication for the bytes covered by the MAC. Replay resistance comes from additional protocol state: authenticated freshness data, a bounded acceptance rule, and, when required, duplicate tracking or idempotent processing.

That separation keeps the design precise. The receiver can state which bytes are authenticated, how long a signed request remains acceptable, what constitutes a duplicate, and which application effects are protected from repetition. Those are distinct properties, and each needs an explicit mechanism.

References