Verifying a webhook signature: doing it right
A webhook is a URL of your application that someone else calls. Anyone can guess that URL and send it a request that looks like an event. Without signature verification, a single fake "delivered" or "bounce" event is enough to corrupt your data. Here is how to verify every request, and the pitfalls that make verification fail even though everything seems correct.
The examples use Duva's webhooks, whose signature follows the open "Standard Webhooks" format: any compatible library verifies it.
What you receive
Each event arrives as a JSON POST, with three headers:
POST /hooks/duva HTTP/1.1
content-type: application/json
webhook-id: evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d
webhook-timestamp: 1789843104
webhook-signature: v1,g0hM9SsE+OeSQNLzdvDx0IHdBBu58Z8ZGkTmDd9wSQ4=
{"data":{"detail":{"code":550,"enhanced_code":"5.1.1"},"message_id":"msg_9f1c…","recipient":"[email protected]"},"domain":"example.com","id":"evt_0b1c…","type":"bounced"}
webhook-ididentifies the event;webhook-timestampis the sending time (in seconds);webhook-signaturecontains one or more signatures of the formv1,<base64>.
The signature is base64(HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<raw body>")). The secret is the base64 part of the whsec_... secret, shown only once, when the webhook is created.
The four rules
1. Verify on the raw body
This is mistake number one. The signature covers the exact bytes received. If your framework decodes the JSON and you then re-encode it to verify, the key order, the whitespace or the accented characters can change, and the signature no longer matches.
So read the body before any decoding:
- Node.js (Express):
express.raw({ type: "application/json" }); - Flask:
request.get_data(); - PHP:
file_get_contents("php://input"); - Go:
io.ReadAll(r.Body); - Rails:
request.raw_post.
2. Reject a timestamp that is too old
Without a timestamp check, someone who intercepts a valid request can replay it forever: the signature will stay valid. Reject any webhook-timestamp older than 5 minutes (and any timestamp in the future).
3. Compare in constant time
Compare the expected signature and the received one with a constant-time comparison function (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js), not with ==. This avoids revealing, through response time, how many characters were correct.
4. Accept several signatures and several secrets
The header can contain several signatures separated by a space, for example during a secret rotation. It is enough that one is valid. On your side, also accept several secrets during the rotation: try them all, and remove the old one when nothing uses it anymore.
With the official library
The simplest and safest way is to use the provided function: it does the four things above.
Node.js
import { constructEvent, WebhookSignatureError } from "duva-mail";
// req.headers and the raw body, exactly as received
try {
const event = constructEvent(process.env.DUVA_WEBHOOK_SECRET, req.headers, rawBody);
console.log(event.type, event.data);
} catch (err) {
if (err instanceof WebhookSignatureError) {
res.status(400).end();
return;
}
throw err;
}
Python
import os
from duva import WebhookSignatureError, construct_event
try:
event = construct_event(os.environ["DUVA_WEBHOOK_SECRET"], request.headers, raw_body)
print(event.type, event.data)
except WebhookSignatureError:
return "", 400
Equivalent functions exist for PHP, Go and Ruby: see the API reference.
And without a library?
If you prefer not to add anything to your project, the verification fits in a few lines. Here is a Python version, written for a single secret:
import base64
import hashlib
import hmac
import time
def verify(secret: str, headers: dict, raw_body: bytes, tolerance: int = 300) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
message_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False # too old (replay) or in the future
signed = f"{message_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
received = [part[3:] for part in headers["webhook-signature"].split() if part.startswith("v1,")]
return any(hmac.compare_digest(expected, candidate) for candidate in received)
Add a try/except around the header access (a missing header must never crash the server) and, during a rotation, call the function with each active secret. We checked it against Duva's public test vectors (modified body, timestamp too old or in the future, wrong signature, unknown signature, missing header): it gives the expected result for each one, except rotation, which requires trying several secrets.
After the verification
Respond fast, process afterwards
Duva waits for a 2xx response for 10 seconds at most. Verify the signature, put the event in a queue (or write it to the database), reply 200, then do the heavy work. A slow processing that exceeds the delay is seen as a failure, and the event will be sent again.
Reply 2xx only after verifying the signature: a 2xx on an unverified request tells the sender that everything is fine.
Deduplicate
Delivery is at least once: the same event can arrive more than once (same webhook-id, new timestamp and new signature). Keep the identifiers you have already processed and ignore a duplicate. Ideally, make your processing idempotent: applying "the message is delivered" twice should change nothing.
Do not count on order
No order is guaranteed between events. A delivered can arrive before an older deferred. Rely on occurred_at, and on the "a status never moves back" logic.
What happens when it fails
- Any response other than
2xx, a 10-second timeout, a connection or certificate error: new attempt after 30 seconds, then doubling up to 1 hour, for 10 attempts in total; - redirects are not followed: a redirect is a failure;
- replying
410disables the webhook: useful to say "stop sending"; - after 100 consecutive failures, the webhook is disabled and its pending deliveries are abandoned.
A checklist
- The body is read raw, before any decoding.
- A timestamp older than 5 minutes is rejected.
- The comparison is constant-time.
- An invalid signature returns a
4xxand has no effect in your application. - The
whsec_...secret is in an environment variable, never in the repository or in a log. - Events are deduplicated on
webhook-id. - The response takes less than 10 seconds.
- Your endpoint is on HTTPS: it is required, and it must be reachable from the public Internet (private and local addresses are refused).
- The full body of events is not written as-is to your logs: it contains email addresses, which are personal information.