Verify webhook signatures before trusting task results
A webhook receiver is an unauthenticated public endpoint by definition. Anyone who finds the URL can POST a fake task.completed and poison your data. Signature verification is the one line of defense — here’s how to do it correctly.
What the signature proves
Every delivery carries a signature header computed over the raw request body with your webhook secret. Verifying it proves the payload came from us and wasn’t modified in transit. The timestamp in the header bounds replay: reject events older than a few minutes even with a valid signature.
Python
import hashlib, hmac, time
from flask import request
SECRET = "whsec_…"
def verify() -> bool:
sig = request.headers.get("X-Signature", "")
ts = request.headers.get("X-Timestamp", "")
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
@app.post("/hook")
def hook():
if not verify():
return "bad signature", 401
event = request.get_json()
if event["type"] == "task.completed":
store(event["task"]["result"])
return "", 200
Node
import crypto from "node:crypto";
function verify(rawBody: Buffer, sig: string, ts: string): boolean {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
The mistakes that matter
- Verify the raw body, not the parsed-and-reserialized JSON — key ordering changes the bytes.
- Use constant-time comparison (
hmac.compare_digest,timingSafeEqual) —===leaks timing. - Check the timestamp — without it, a captured request replays forever.
- Return 200 only after storing (or enqueue first) — a 5xx triggers retries, which is what you want on transient failures but not after success.
Full delivery format, retry schedule and event types in the webhooks guide.