Development

Signing Webhook Payloads: HMAC, Timestamps, and Replay Defense

Antonio Evans10 min read

A close-up of a computer screen showing a code editor with a function named verifySignature.
Key points

Key points

  1. 1Sign an HMAC-SHA256 digest over a canonical string that includes a version prefix, a timestamp and the raw request body, then send the digest and timestamp as headers.
  2. 2Reject any request whose signed timestamp falls outside a small tolerance window (5 minutes is a common default) and also reject timestamps far in the future.
  3. 3Compare signatures with a constant-time function over equal-length byte buffers, never with plain string equality.
  4. 4Block replays inside the tolerance window by deduplicating on a stable event ID or nonce, and rotate secrets by signing with two keys during a transition period.

Why the raw body has to be the signed bytes

Almost every verification bug traced back to the same root cause: the code signs something other than what the provider signed. A framework that parses JSON and re-serializes it changes whitespace, key order, unicode escaping and number formatting, so the recomputed digest no longer matches. GitHub's own docs are blunt about this: the signature is computed over the payload contents, payloads may contain unicode, and you must treat the body as UTF-8 GitHub's validation guide. Shopify integrations hit the same trap; the reliable pattern is to capture the raw body buffer in middleware and verify against that buffer before any parsing happens a Shopify reliability walkthrough.

The practical rule: keep a raw byte buffer on the request object, verify, and only then parse.

// Express: capture bytes before any JSON parser touches them
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

app.post('/webhooks', (req, res) => {
  if (!verify(req.rawBody, req.headers)) return res.status(401).end();
  // safe to use req.body from here
});

A second trap sits closer to the signing side. Stripe's format packs the timestamp and signature into one header (t=...,v1=...), which means the timestamp itself is inside the signed structure; some providers instead put the timestamp in a separate header, and if you forget to fold that header into the digest, an attacker can change the timestamp without breaking the signature webhooks.fyi on replay prevention. Whichever layout you choose, document the exact byte sequence and make it the only thing your library accepts.

Constructing the canonical string

The signed input is not the body alone. It is a deterministic concatenation of a version tag, the timestamp and the raw body, joined by a separator that cannot appear ambiguously. webhooks.fyi recommends concatenating body, timestamp and sensitive headers, and explicitly warns against adding unnecessary steps such as changing capitalization or inserting line breaks provider best practices. A version prefix gives you forward compatibility: if you later move to a new algorithm or add fields, the receiver can branch on the prefix instead of guessing.

A workable layout:

v1:1712345678:{"id":"evt_123","type":"payment.succeeded"}

Two rules make this safe. First, the separator must not be forgeable from the parts: use a character that cannot appear in a decimal timestamp, and prefix the body length if you want to eliminate all ambiguity. Second, sensitive headers (a tenant ID, a key ID) belong inside the digest if the receiver makes routing decisions from them, because an unsigned header can be swapped.

Then compute HMAC with SHA-256 and encode as hex or base64:

import hmac, hashlib, time

def sign(secret: bytes, body: bytes, ts: int) -> str:
    msg = b"v1:" + str(ts).encode() + b":" + body
    return hmac.new(secret, msg, hashlib.sha256).hexdigest()

headers = {
    "X-Signature-Version": "v1",
    "X-Timestamp": str(int(time.time())),
    "X-Signature": sign(SECRET, raw_body, int(time.time())),
}

Use one tool for the job on the receiving side rather than hand-rolling across languages. Standard Webhooks publishes the exact header names, encoding and verification algorithm, plus reference libraries in Python, JavaScript/TypeScript, Java/Kotlin, Rust and Go Standard Webhooks, the spec. If you are interoperating with a partner who follows it, adopting it removes a negotiation.

Algorithm choiceStatusUse it?
HMAC-SHA256Recommended minimum by webhooks.fyi, dominant in the wildYes
HMAC-SHA512Stronger digest, slightly slower, less commonFine, if both sides support it
HMAC-SHA1Deprecated for new workNo
HMAC-MD5Cryptographically brokenNever
Plain shared secret in a headerSecret travels on every requestNo, use HMAC

webhooks.fyi's security guidance states it plainly: do not use SHA-1 or MD5, and for HMAC use at least SHA-256 webhooks.fyi HMAC. Hookdeck's analysis lists the three factors that set the real strength of an HMAC: the hash function, the digest size, and the length and randomness of the key Hookdeck on SHA-256 verification. That last factor is the one teams neglect: a 12-character passphrase chosen by a human is the weak link even with SHA-256.

Comparing in constant time, and failing closed

A byte-by-byte equality check on a signature leaks information through timing: the comparison returns early on the first mismatch, so an attacker who can measure response times can recover a valid signature one byte at a time. GitHub's documentation tells implementers never to use a plain == operator and to use a constant-time comparison such as crypto.timingSafeEqual or Rack's secure_compare GitHub's validation guide. The reference implementation on webhooks.fyi checks buffer lengths first and then calls crypto.timingSafeEqual webhooks.fyi HMAC.

Three details make this reliable in practice:

  • Compare bytes, not strings. Convert both sides to Buffer or bytes with a known encoding before the call.
  • Check length before calling the constant-time function. Some implementations throw on length mismatch, and that exception is itself an information leak if it produces a different response.
  • Strip the algorithm prefix from the header before comparing, but parse it strictly. GitHub sends sha256=<hex>; a permissive parser that accepts an empty prefix invites confusion later GitHub's validation guide.

Failing closed matters as much as comparing safely. A missing signature header, an unknown version, a malformed timestamp or a body that exceeds your size limit should all produce a rejection before any business logic runs. Log the rejection with the delivery ID so you can tell a broken integration from an attack, and return a non-2xx status so the sender's retry logic does the right thing.

Timestamp validation and clock skew

The timestamp is what turns a signature from "this was signed by someone with the key" into "this was signed recently." Without it, a captured request stays valid forever.

The algorithm on receipt:

  1. Parse the timestamp header as a decimal integer of seconds since the Unix epoch. Reject anything that is not a clean integer.
  2. Compute now - timestamp. If the result is greater than the tolerance, reject as expired.
  3. Compute timestamp - now. If the result is greater than the tolerance, reject as future-dated.
  4. Only then verify the signature over the canonical string that includes the timestamp.

That second direction is the one teams forget. A signature with a timestamp ten years in the future passes a naive "not older than five minutes" check, and if you cache or store the event, the attacker has effectively parked a valid request for later. webhooks.fyi's examples focus on the stale-request case, but the same tolerance should apply symmetrically replay prevention.

Choosing the window is a tradeoff you can reason about with numbers:

  • Too tight (30 seconds): legitimate deliveries fail whenever either side's clock drifts, and NTP-synchronized hosts can still be tens of milliseconds to a few seconds apart after a leap event or a VM pause.
  • Too wide (1 hour): a captured request remains useful for an hour, which is plenty of time for an attacker who has already seen it.
  • Five minutes: Stripe's documented default for blocking replays, and the figure Svix recommends, described as old enough for clock skew and short enough that a captured request expires before it is useful Svix's webhook security guide, Devlume's reliability summary.

webhooks.fyi suggests a 3 to 5 minute window and notes that reliable timestamp validation depends on keeping the listener's clock in sync, with NTP as the fix replay prevention. Operationally: run a time daemon on every host that terminates webhooks, alert when offset exceeds about one second, and never run verification on a container host with a frozen clock after a long suspend.

A worked example makes the failure mode concrete. Suppose your tolerance is 300 seconds and a delivery arrives with timestamp T, and your server's clock is 400 seconds slow because NTP died during a migration. now - T evaluates to 100 seconds, which passes. The request looks fine. But every request now looks 400 seconds fresher than it is, so your effective replay window has widened to 700 seconds, and a request captured 690 seconds ago still passes. Clock skew does not just cause false rejections; it silently widens the window. Monitoring offset is the control.

If you need to test the logic without waiting, inject a clock:

def fresh(ts: int, now: int, tol: int = 300) -> bool:
    delta = now - ts
    return -tol <= delta <= tol

Negative deltas cover the future-dated case; a single comparison handles both directions.

Replay defense beyond timestamps

A timestamp bounds how long a captured request stays useful. It does not stop an attacker from resending the same valid request many times inside that window. Duplicate deliveries are also a normal, documented property of webhooks: most providers deliver at least once, so you will see the same event twice without any attacker involved Hookdeck on idempotency, Hookdeck on webhooks at scale.

The right structure is one deduplication step that serves both purposes. Deduplicate on a stable identifier the provider sends (GitHub's X-GitHub-Delivery header is unique per delivery and is preserved on manual redelivery GitHub's best practices) or on a nonce you require the sender to include in the signed string.

The claim must be atomic. A check-then-insert leaves a race where two copies of an event both pass the "have I seen this?" check before either records it; a unique constraint or INSERT ... ON CONFLICT DO NOTHING lets the datastore arbitrate Prismatic on idempotency. A Redis SET key value NX EX ttl does the same job with less ceremony the Shopify walkthrough.

Sizing the deduplication window is where teams get it wrong. If your signature tolerance is 5 minutes but a provider retries for three days, a 1-hour TTL protects only the first hour. Prismatic's guidance is to keep the dedup window longer than the provider's retry window, because a short TTL against a long retry period leaves a gap Prismatic on idempotency. For replay defense specifically, the window only needs to cover your tolerance plus clock skew, so about 10 minutes of key retention is enough to make a replayed signature useless. For retry deduplication, you need the full retry horizon. Keep two windows if you need both.

ControlWhat it stopsWindow to size
HMAC verificationSpoofing and body tamperingNot time-bound
Timestamp toleranceReplays of old capturesTolerance plus max clock skew
Nonce or event-ID storeReplays inside the tolerance windowTolerance times two, minimum
Idempotency keys on side effectsDuplicate processing from retriesLonger than the provider's retry horizon

Also consider what a replay can do even when it is detected. If processing means "set order status to paid," running it twice is harmless. If it means "increment balance by 2000," it is not. Absolute state changes are retry-safe; increments are not Prismatic on idempotency. Where you cannot avoid a relative change, gate it on the idempotency claim so the second attempt never reaches the mutation.

Key management: rotation without downtime

The secret is the whole security model. webhooks.fyi's provider guidance is to individualize secrets per listener (one secret per endpoint, not one per account), use high-entropy random values, and implement rotation with zero downtime by signing with multiple keys during the transition provider best practices. GitHub recommends a random string with high entropy and storage in a secure location your server can reach, never hardcoded and never committed GitHub's validation guide.

Rotation that actually works looks like this:

  1. Generate the new secret and store it alongside the old one on both sides. The receiver accepts signatures from any active secret; the sender signs with the newest.
  2. Wait at least one full retry horizon (and longer than your delivery timeout) so in-flight retries signed with the old key still verify.
  3. Revoke the old secret. Confirm that no request has verified against it for a period longer than the retry horizon, typically by logging which key ID matched.

The key ID belongs in a header and inside the signed string, so it cannot be swapped to force verification against a weaker or retired key. ngrok's survey of 100 webhook providers found that only 8.5% offered zero-downtime secret rotation and only 27% provided versioning for forward compatibility ngrok's field guide. If you are building the sending side, those are cheap differentiators to beat.

For higher assurance, asymmetric signatures remove the shared-secret problem entirely: the sender signs with a private key and publishes the public key at a JWKS endpoint, so a leaked receiver configuration cannot be used to forge requests. FusionAuth's webhook signing docs describe RSA, EC and EdDSA keys with the public half exposed via /.well-known/jwks.json, plus a JWT header carrying a request_body_sha256 claim FusionAuth's signing docs. That design also makes rotation easier, since receivers can fetch the new public key rather than coordinate a secret exchange.

When the HMAC path is the right one (it usually is: lower latency, no key distribution infrastructure), an agent or service that terminates many third-party webhooks benefits from a single verification layer rather than per-integration code. That is the kind of plumbing a platform handles for you; Quanta Inference gives you one API key and one OpenAI-compatible surface for the models that process those events once they are verified (https://quantaintelligence.ai/inference).

A verification checklist you can ship

Put this in the pull request that adds a webhook endpoint, and require each line to be true before merge.

CheckWhy it matters
Raw body captured before parsingRe-serialization breaks every signature
Version prefix in the canonical stringLets you migrate algorithms or fields
Timestamp included in the digestUnsigned timestamps can be edited
Tolerance enforced in both directionsStale and future-dated requests both fail
NTP running, offset alertedSkew silently widens the window
Constant-time compare with length pre-checkBlocks timing attacks
Missing or malformed headers rejected before workFail closed, not open
Secure ID hashing with hmac/hashlib, not hash()Language hash functions are not for this
Event ID or nonce claimed atomicallyStops replays and duplicates
Dedup window longer than retry horizonPrevents late duplicates
Two active secrets during rotationNo downtime, no dropped deliveries
Rejections logged with delivery IDDistinguishes breakage from attack

Two more items worth adding for production: an IP allow list where the provider publishes its ranges (GitHub exposes them through its meta endpoint GitHub's best practices), and a response that returns quickly so the sender does not retry a slow handler (GitHub terminates connections that do not get a 2XX within 10 seconds GitHub's best practices). Neither replaces the signature, but together they cut noise.

If you are designing this from scratch for a product rather than integrating as a consumer, Standard Webhooks is the reference to copy: it defines headers, encoding, and the exact signing string, with libraries you can depend on instead of debugging your own canonicalization for the third time Standard Webhooks, the spec. webhooks.fyi tracks it alongside IETF HTTP Message Signatures and CloudEvents for teams that need a standard beyond webhooks standardization efforts.

FAQ

FAQ

Should I sign the timestamp separately or inside the same string as the body?

Sign it inside the same canonical string, as part of the HMAC input. If the timestamp sits in its own field and is not covered by the digest, an attacker can rewrite it to keep an expired capture valid, which is exactly the failure webhooks.fyi warns about replay prevention. A single digest over version, timestamp and body is simpler to verify and harder to get wrong.

Is a 5 minute tolerance always the right choice?

It is a good default that balances clock skew against replay exposure, and it is what Stripe and Svix document Svix's guide. Tighten it if both sides run disciplined NTP and you control the sender; widen it only if you see genuine false rejections, and fix the clocks first. Remember that skew on your own host widens the effective window even when the tolerance is nominal.

Do timestamps alone stop replay attacks?

No. A timestamp only bounds how long a captured request stays useful; inside that window it can be resent indefinitely. You need a nonce or event ID stored with an atomic claim so the second copy is recognized Prismatic on idempotency. Treat timestamps and deduplication as two layers of one control, not alternatives.

What happens if I rotate the secret and my receiver only knows the new one?

Deliveries already in flight and signed with the old key fail verification, and depending on the provider those retries may be exhausted. The safe pattern is to accept any active secret during the transition, wait at least one retry horizon, then revoke the old one; webhooks.fyi recommends signing with multiple keys during rotation for exactly this reason provider best practices.

How do I test verification without waiting for a real timestamp to expire?

Inject the clock. Write the freshness check as a pure function of timestamp, now and tolerance, then unit-test it with fixed values: exactly at the boundary, one second past, one second in the future. For the signature itself, use a published test vector, such as GitHub's documented secret and payload with its expected sha256= digest, to confirm your canonicalization and encoding match the reference GitHub's validation guide.

Sources

Sources

  1. Validating webhook deliveries - GitHub Docs
  2. Reliable Shopify Webhooks: Idempotency, Retries, and ...
  3. Replay prevention - Docs
  4. Best Practices for Webhook Providers - Docs
  5. Standard Webhooks
  6. standard-webhooks.md - GitHub
  7. Hash-based Message Authentication Code (HMAC) - Docs - Webhooks.fyi
  8. How to Implement SHA256 Webhook Signature Verification - Hookdeck
  9. Webhook Security Best Practices | Svix Resources
  10. Webhook reliability: retries, idempotency, and the design ...
  11. How to Implement Webhook Idempotency - Hookdeck
  12. Webhooks at Scale: Best Practices and Lessons Learned - Hookdeck
  13. Best practices for using webhooks - GitHub Docs
  14. How Do I Make Webhook Processing Idempotent - Prismatic.io
  15. Webhook Security in the Real World
  16. Sign Webhooks | FusionAuth Docs
  17. Standardization Efforts - Docs - Webhooks.fyi
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles