Webhook Signing: HMAC, Ed25519, and the Key Rotation Story Most APIs Skip

Webhook signature verification protects integrations from forgery, replay, and accidental misrouting. Almost every API gets the basic case right and the rotation story wrong, which is why secret rotation is the security improvement most teams quietly never make. The patterns that work in production.

A signed webhook is a request that the recipient can verify came from the sender they expect. The mechanism is straightforward — the sender computes a signature over the payload using a shared secret or private key, the receiver recomputes and compares — but the details that make the system survive in production take longer to get right than the basic implementation. We've shipped signed webhooks across DocuMint, CronPing, FlagBit, and WebhookVault, and the patterns that hold up are the ones that handle key rotation, replay protection, and edge cases the basic specification doesn't address.

HMAC vs Ed25519: which to choose

HMAC with SHA-256 is the dominant choice across the industry. Stripe, GitHub, Slack, and most major APIs use it. The reason is operational simplicity: there is one shared secret per webhook endpoint, the receiver has the same secret the sender does, and signature verification is a single hash comparison. The cryptographic security is excellent — HMAC-SHA256 has no known practical attacks — and the implementation surface is tiny.

Ed25519 (or any asymmetric scheme) is the right answer when the receiver should be able to verify signatures without holding the secret that could create them. Public-key signatures let you publish the verification key freely while the signing key stays private. The trade-off is operational complexity: you now have a key pair, a public-key publishing mechanism, and rotation flow that involves both sides.

For most B2B SaaS webhook integrations, HMAC is correct. For platforms that want third parties to verify signatures without coordinating with the sender (think: webhook events that fan out to many independent receivers), Ed25519 starts to make sense. We use HMAC for all four products because every receiver is a known integrator who can hold a secret.

The basic signing format

The signature should be computed over the raw bytes of the request body, not the parsed JSON. Parsing reformats whitespace and field order in ways that change the bytes hashed; receivers that re-serialize from parsed JSON to verify will fail intermittently. The signing string should also include a timestamp, which protects against replay (more on this below).

The standard pattern, modeled on Stripe's:

signed_payload = timestamp + "." + raw_body
signature = hex(hmac_sha256(secret, signed_payload))
header = "t=" + timestamp + ",v1=" + signature

The header is sent in something like X-Anethoth-Signature. The receiver parses the timestamp and signature, recomputes, and compares. The version prefix (v1=) lets you add a second algorithm later without breaking existing receivers — a small detail that pays off when you eventually want to migrate from HMAC to Ed25519, or from SHA-256 to SHA-3.

Timing-safe comparison and the off-by-one bug

Signature comparison must use a constant-time function. A normal string equals leaks information about how many leading bytes match, which is enough to mount a remote timing attack against the signature given enough requests. Every language standard library has a constant-time comparison primitive (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, subtle.ConstantTimeCompare in Go); use it. This is the single security bug most likely to be introduced and most likely to be missed in code review.

Replay protection

A valid signature on a captured webhook stays valid forever unless the receiver rejects old timestamps. Without a timestamp window, an attacker who captures a single delivery (perhaps via a misconfigured proxy log) can replay it indefinitely.

The fix is to include the timestamp in the signed payload (which we did above) and reject deliveries with timestamps outside a tolerance window. Five minutes is the conventional value: large enough to absorb client-clock skew and network latency, small enough that captured signatures expire before they are useful. The receiver also needs idempotency on event ID, because legitimate retries within the window should be treated as duplicates rather than new events.

Key rotation: the part most APIs skip

Every secret eventually leaks, gets accidentally committed, or needs to be rotated for compliance. APIs that ship without a rotation story end up with secrets that have been the same for years, which is a much worse security posture than a rotation policy that occasionally requires customers to update configuration.

The pattern that works: each webhook endpoint can have multiple active signing secrets simultaneously. The sender signs with the most recent; the receiver tries each active secret until one verifies. Adding a new secret marks it as active and primary; the old secret stays active but secondary. After a tolerance period (we use 30 days), the old secret is removed.

This pattern lets the customer rotate at their own pace. They generate the new secret in the dashboard, deploy the new value to their receiver, then click a button to remove the old secret. No coordinated cutover, no race condition, no dropped events. The implementation is a one-to-many relationship between webhook_endpoints and webhook_signing_secrets, with each secret carrying an active_until nullable column.

The signing-secret-as-API-key conflation

A common mistake is using the customer's main API key as the webhook signing secret. The two have different lifecycles and different exposure surfaces. The API key is held by the customer's application code that calls your API; the signing secret is held by the customer's webhook receiver. They are usually different services, often deployed by different teams. Conflating them means rotating the API key requires updating the webhook receiver, and vice versa. Keep them separate from day one.

What the receiver should reject

The receiver should reject deliveries that fail any of: missing signature header, malformed signature header, timestamp outside the tolerance window, signature that does not match any active secret, body that has been mutated between transport and verification (a problem with some HTTP frameworks that read and re-serialize the body).

The response code matters: rejected requests should return a non-2xx status (we use 400 Bad Request for malformed signatures, 401 Unauthorized for verification failure) so the sender's retry logic can react appropriately. Returning 200 on a verification failure makes debugging integration problems on both sides much harder.

The deeper observation

Webhook signing looks like a small cryptographic problem and is mostly a key-management and operational problem. The HMAC primitive is twenty years old and well understood; the bugs that cause real outages are the ones that come from not handling rotation, not handling replay, conflating signing secrets with API keys, or comparing signatures non-constant-time. Investing in the rotation story up front is the single most underrated security improvement we have made across these four products.

Read more