All resources
Security10 min read

Webhook signature verification from raw bytes to replay defense

A detailed guide to verifying webhook authenticity without letting parsing, logging, or clock drift weaken the trust boundary.

A practical PingFlow guide for developers working at the boundary between systems.

At a glance

Key takeaways

  • Authentication starts before JSON parsing
  • Understand the signed message format
  • Use constant-time comparisons and bounded inputs
In this guide

Authentication starts before JSON parsing

A webhook signature authenticates a byte sequence, not an object in memory. The sender computes a digest over a precisely defined message, often a timestamp joined to the raw request body, and signs that digest with a shared secret or private key. The receiver must verify the same bytes before a framework transforms them. JSON parsing can change whitespace, escape sequences, number formatting, and key ordering. If the receiver verifies a re-serialized object, it may reject valid requests or, worse, verify a different representation than the sender signed.

Capture the raw body once and pass it through the verification function without logging it. The verification function should receive bytes or an explicitly defined encoding, the complete signature header, and a clock source that can be tested. Only after verification succeeds should the body be parsed into a domain object. This ordering is a security invariant. Treat it like an authorization check: easy to move accidentally, expensive to recover after it has been weakened.

Understand the signed message format

Providers differ in how they construct the signed message. Some sign the raw body directly. Others prepend a timestamp, an event ID, or a version marker. The signature header may contain multiple schemes, key IDs, timestamps, and signatures separated by delimiters. Never guess the concatenation rule from a convenient example. Implement the provider's documented grammar and test the parser against whitespace, repeated fields, unknown fields, and malformed values.

Use a versioned verifier so a provider can rotate its scheme without forcing a risky rewrite. Select a verification key by the header's key ID when the provider supports key rotation, but reject unknown IDs rather than trying every secret. Comparing every configured secret can turn a misconfiguration into a confusing success and may create timing differences. Make the accepted algorithms explicit. A verifier that silently accepts an unexpected algorithm is not a flexible verifier; it is an ambiguous trust boundary.

Use constant-time comparisons and bounded inputs

After computing the expected signature, compare it with the received signature using a constant-time comparison appropriate for the representation. A normal string equality check can short-circuit on the first differing character. In many environments the practical risk is small, but the secure default is straightforward and removes an avoidable signal. Decode both values into the same byte representation first; comparing a hex string to a base64 string is a format bug, not a cryptographic decision.

Bound the amount of work before verification. Limit header length, body size, number of signatures, and timestamp token length. A webhook endpoint is internet-facing, so an attacker can send a huge header or body designed to consume CPU and memory before the request is rejected. Return a generic authentication error without explaining whether the timestamp, key ID, or digest failed. Detailed reasons belong in protected diagnostics, not in a response an attacker can probe repeatedly.

Defend against replayed valid requests

A valid signature proves that an authorized sender signed the message; it does not prove that the message is new. A captured request can be replayed by anyone who obtains it unless the receiver enforces freshness and uniqueness. Require a timestamp within a bounded window, accounting for reasonable clock skew. Then record a provider event ID, nonce, or digest so the same signed message cannot be accepted repeatedly. Freshness without uniqueness still allows rapid duplicates within the window.

Choose the replay record's retention based on the provider's retry horizon and your business risk. A payment event may need a longer record than a low-value analytics event. Make the replay check atomic with acceptance so two concurrent copies cannot both pass a read-then-write race. If a sender does not provide a unique ID, derive a digest from the signed message and include the timestamp in the key. Explain this rule in the runbook so operators do not delete the record while a provider is still retrying.

Rotate keys without breaking delivery

Key rotation should have an overlap period. Add the new verification key, accept the old and new key IDs for a planned window, and monitor which key is being used. After the sender confirms the new key is active and retries have drained, remove the old key. If the provider supplies one secret without key IDs, use a primary and fallback verifier only during a tightly bounded transition and record which one accepted each request. Never leave a retired key active indefinitely because it becomes an attractive credential for forged requests.

Keep signing secrets out of source control, browser bundles, logs, and error messages. Secret Manager or an equivalent service should provide access control, audit trails, and versioning. The application should load the secret at startup or through a supported runtime mechanism, not fetch it from a user-controlled request. Test the missing-secret path: the endpoint should fail closed and produce an operationally useful alert without returning internal configuration details.

Build fixtures before the first incident

Create a fixture set from the provider's examples and your own captured, redacted requests. Include a valid signature, a changed body, a changed timestamp, a malformed header, an unknown key ID, a duplicate signature, a body with Unicode, and an empty body if the endpoint permits one. Test both acceptance and rejection. Add a property test for parser boundaries if the header grammar is complex. The fixture should preserve raw bytes, not only a parsed JSON object.

Exercise the full HTTP stack with a request whose body is read by middleware, a request parser, and the verifier. Framework upgrades can change when the body stream is consumed. A unit test around a pure verifier will not catch a middleware that has already decoded or normalized the body. Log only a stable request ID, the key ID, and the result. The fixture suite becomes a small security regression net that is cheap to run on every deployment.

Make failures useful without making them leaky

To an external caller, invalid, expired, and unknown-key signatures should look similar. Distinguishing them helps attackers enumerate your configuration. Internally, use structured categories such as malformed header, unsupported scheme, stale timestamp, unknown key, digest mismatch, and duplicate event. Count those categories and alert on unusual changes. A sudden rise in malformed headers may be a provider integration bug; a rise in valid signatures with duplicate IDs may be a retry storm or a replay attempt.

A secure verifier is not finished until its operational contract is clear. Document the accepted clock skew, body limit, retry behavior, response status, key rotation procedure, and redaction rules. When you need to inspect what a sender actually sent, use a controlled payload inspector and remove secrets before sharing the capture. Security and debuggability are not opposites when the boundary is designed intentionally.

Implementation example

Verify the signature against the untouched request bytes before JSON parsing, normalization, or schema coercion. Parse the provider's timestamp and signature fields separately, enforce a bounded freshness window, and compare MACs in constant time. Keep the verification secret server-side and return a generic failure response that does not reveal which check failed.

typescript
const expected = createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`, 'utf8')
  .digest('hex');
const valid = timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

Verify and troubleshoot

Create fixtures for a valid body, one-byte body change, stale timestamp, malformed signature, wrong secret, duplicate signature, and rotated secret. Log only a request ID, provider event ID, and verification outcome. If a valid request fails, compare raw-byte length, encoding, proxy transformations, timestamp units, and which secret version the revision loaded before changing the algorithm.

Operations and recovery

Rotate secrets with an overlap window when the provider supports multiple active keys, then remove the old key after retries drain. Alert on signature failures by provider and route, but avoid logging the signature or body. A suspected secret leak requires rotation and replay review; disabling verification is not an acceptable emergency bypass.

References and further reading

Use the provider's signed-webhook specification, RFC 2104 for HMAC, and the OWASP Webhook Security guidance. Record the exact canonical string and timestamp tolerance in the service contract.

Continue hands-on

Ready to inspect the problem?

Open the Webhook Inspector

Keep exploring