Verify a webhook signature
Every webhook delivery from RolesAPI carries a signature so your endpoint can prove the request came from us and was not replayed. This recipe gives you drop-in verifiers for Node and Python.
The header looks like:
x-rolesapi-signature: t=1751362361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex, where secret is the whsec_... value returned once when you created the webhook (reference).
Node
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, signatureHeader, secret) { // rawBody must be the exact bytes received -- use express.raw() or // equivalent, never JSON.stringify(req.body). const parts = Object.fromEntries( signatureHeader.split(",").map((p) => p.split("=")) ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return false;
// Reject stale or future-dated timestamps. if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex");
const a = Buffer.from(expected, "hex"); const b = Buffer.from(v1, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express usage:// app.post("/hooks/rolesapi", express.raw({ type: "application/json" }), (req, res) => {// const ok = verifySignature(req.body, req.get("x-rolesapi-signature"), process.env.ROLESAPI_WEBHOOK_SECRET);// if (!ok) return res.status(400).send("bad signature");// const event = JSON.parse(req.body);// res.sendStatus(200);// });Python
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: """raw_body must be the exact request bytes, before JSON parsing.""" parts = dict(p.split("=", 1) for p in signature_header.split(",")) t = parts.get("t") v1 = parts.get("v1") if not t or not v1: return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False
expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256, ).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask usage:# @app.post("/hooks/rolesapi")# def hook():# ok = verify_signature(# request.get_data(), # raw bytes# request.headers.get("x-rolesapi-signature", ""),# os.environ["ROLESAPI_WEBHOOK_SECRET"],# )# if not ok:# abort(400)# event = request.get_json()# return "", 200Why the 300-second tolerance on t?
The t timestamp is part of what gets signed, so an attacker cannot change it without breaking the signature. Rejecting deliveries where t is more than 300 seconds from your clock means a captured request cannot be replayed later — after five minutes it is dead, even with a valid signature. Five minutes is generous enough to absorb ordinary clock drift and retry delay; if you see valid deliveries failing the window, check your server’s clock synchronization before widening it.
Why a timing-safe compare?
A plain === or == string comparison returns as soon as the first character differs, so how long it takes leaks how much of an attacker’s guess was correct — with enough tries, that leak can be walked into a full forgery. crypto.timingSafeEqual and hmac.compare_digest take the same time whether the guess is wrong at the first byte or the last, closing that channel. Always use them for signature checks.
Common mistakes
- Re-serializing the body. Sign checks must use the raw received bytes. Parsing to JSON and re-stringifying can reorder keys or change whitespace, and the HMAC will not match.
- Comparing against the whole header. Parse out
v1; the header also containst=. - Returning 200 before verifying. Verify first, then acknowledge, then process — ideally asynchronously, so slow processing does not cause a retry and a duplicate.