Webhook signature verification is how a receiving system proves a webhook really came from the sender and wasn't changed on the way. The sender signs the payload with a shared secret (HMAC) or a private key, and the receiver recomputes the signature over the exact bytes it received and compares the two. Add a timestamp check and event-ID deduplication, and the same mechanism also stops replayed requests.
This guide covers how verification works, which signature scheme to choose, how to handle retries and replays safely, and the operational habits that keep it working after go-live. If you're new to webhooks, start with what a webhook is and how it works.
Why webhook endpoints need more than HTTPS
A webhook endpoint is a public URL that accepts POST requests and acts on them: creating orders, updating records, triggering payouts. HTTPS protects the request in transit, but it doesn't tell you who sent it. Anyone who finds the URL can post a convincing fake event.
Three threats matter in practice:
- Spoofing: a forged event that looks like it came from Stripe, Shopify, or your ERP.
- Tampering: a real event whose body was changed before it reached you.
- Replay: a real, correctly signed event captured and sent again later, for example to trigger the same refund twice.
Signatures solve the first two. Timestamps and event IDs solve the third.
How webhook signature verification works
The sender and receiver agree on a key and an algorithm. For every event, the sender computes a signature over the payload and sends it in a header. The receiver repeats the calculation and accepts the event only if the results match.
The details that decide whether verification works:
- Use the raw request bytes. Most providers sign the body exactly as sent. If your framework parses JSON and you re-serialise it, whitespace and key order change and the signature no longer matches.
- Build the signed string exactly as documented. Some providers sign only the body; others sign
timestamp.bodyor include the request path. Follow the provider's specification. - Normalise the encoding. Signatures arrive as hex or base64, sometimes with a version prefix such as
v1=. Decode to raw bytes before comparing. - Compare in constant time. A normal string comparison can leak timing information. Use your language's constant-time function (
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js). - Reject anything malformed. A missing or unparseable signature header is an authentication failure, not something to let through.
Which signature scheme to use
| Scheme | How it works | Best for | Watch out for |
|---|---|---|---|
| HMAC-SHA256 | Shared secret; both sides compute the same hash (RFC 2104) | Point-to-point integrations | A separate secret per partner, and rotating it safely |
| Public-key (RSA, ECDSA, Ed25519) | Sender signs with a private key; receivers verify with a published public key | Providers with many subscribers | Caching and refreshing the published keys |
| Signed tokens (JWS/JWT) | A signed token carries claims such as an ID and expiry | Teams already using JWT libraries | Replays are still possible until the token expires |
| Mutual TLS | Client certificates authenticate the sender at the connection | A few high-security partners | Certificate management for every sender |
| IP allowlist + HTTPS | Accept requests only from known addresses | An extra layer | Not proof on its own; addresses change and can be shared |
For most integrations, HMAC-SHA256 over a string that includes a timestamp and event ID is the right default. If you're the one sending webhooks to many customers, prefer public-key signatures so you never share a secret with each subscriber.
Handling retries and idempotency
Senders retry until they get a success response or give up, and retry counts and backoff vary by provider. Your endpoint will see the same event more than once, so processing must be safe to repeat.
- Verify the signature first. Return 401 or 403 on failure.
- Enqueue the verified event in a durable queue, then return 2xx. Don't do slow work before acknowledging; the sender may time out and retry.
- Deduplicate on the event ID. Store recent IDs for at least the timestamp window plus the provider's maximum retry period.
- Make side effects idempotent. Use upserts and "already processed" checks so a second delivery changes nothing.
- Send repeated failures to a dead-letter queue for someone to review, rather than retrying forever.
Preventing replay attacks
A replayed event has a valid signature, so verification alone won't catch it. Two checks do:
- A signed timestamp with a short window. Reject events older than a few minutes. Because the timestamp is part of the signed string, an attacker can't change it.
- Event-ID deduplication. Within the window, an event ID you've already seen is ignored.
Allow a small clock-skew margin (30 seconds to a few minutes) and log stale-timestamp rejections separately from signature failures, so a sender with a drifting clock is easy to spot. Timestamp-only checks are simpler but leave the window open to replays; adding event-ID storage closes it at the cost of a small, fast store sized for your peak volume.
Key rotation without downtime
Secrets leak and keys expire, so plan rotation before you need it:
- Keep signing secrets in a secrets manager, never in code or config files.
- During rotation, accept signatures from both the old and the new key.
- Use a signature version header or prefix so you know which key a sender used.
- Retire the old key once its traffic drops to zero, and keep a documented way to revoke a key immediately.
Testing and monitoring
- Test vectors in CI: store a raw body, timestamp, key, and expected signature, and run them on every change to parsing or crypto code.
- Replay tests: resend old and duplicate events in a test environment to confirm they're rejected.
- Local testing: use a tunnel such as ngrok to receive real provider traffic on your machine.
- Metrics and alerts: count signature failures, stale timestamps, and duplicates. A sudden spike in signature failures usually means a key or algorithm changed on the sender's side.
- Logging: keep enough context to debug (headers, a truncated body), and never log secrets or full sensitive payloads.
Frequently asked questions
What is webhook signature verification?
It's a check that a webhook came from the expected sender and wasn't modified. The receiver recomputes the sender's HMAC or public-key signature over the raw request body and compares it with the signature in the request header.
Should I use HMAC or public-key signatures?
Use HMAC-SHA256 for point-to-point integrations where you can store and rotate a shared secret. Use public-key signatures when one sender delivers to many independent subscribers, so no secret has to be shared with each of them.
How do I prevent webhook replay attacks?
Include a timestamp in the signed data and reject events outside a short window, then deduplicate on a unique event ID within that window. The signature stops tampering; the timestamp and ID stop reuse.
Why does webhook signature verification keep failing?
Usually because the signature was computed over a re-serialised body instead of the raw bytes, the encoding (hex or base64) was decoded wrongly, the signed string is missing the timestamp, or a key was rotated on the sender's side.
What should a webhook endpoint return?
Return a 2xx response only after the signature is verified and the event is safely queued. Return 401 or 403 for failed verification, so the sender doesn't keep retrying a request you'll never accept.
Secure webhooks without slowing delivery
Signatures, timestamps, event IDs, and idempotent processing together make webhooks safe to act on, and none of them adds meaningful latency. If you'd like to see webhook-driven workflows with tracing and recovery built in, book a Koodisi demo.