Verifying signatures
Mandatory HMAC-SHA256 verification of Auvral webhooks, with working code in Node, Python, Go, PHP and Ruby.
Signature verification is mandatory
Your webhook URL is reachable by anyone who learns it. Without verification, a forged POST
claiming verification.succeeded is indistinguishable from a real one, and it is the
event that unlocks accounts.
An unverified webhook handler is an open endpoint for granting verified status to anyone.
How the signature is built
Every delivery carries:
x-auvral-signature: v1=6b2f0c...9ae4
x-auvral-timestamp: 1787761964The signature is an HMAC-SHA256, hex-encoded, over the string:
{x-auvral-timestamp}.{raw request body}keyed with your webhook's signing secret (from the webhook's page in the dashboard).
Note the two details that catch people out: a literal . joins the timestamp and the
body, and the value in the header is prefixed with v1= which is not part of the
hash. The prefix is a scheme version, so future schemes can be added without breaking
existing handlers. Match on it rather than blindly stripping the first three characters.
The three rules
1. Read the raw body before parsing it.
The signature covers exact bytes. If your framework parses JSON before you see it, those bytes are gone: re-serializing the object produces different whitespace and key order, and the signature will not match. Every example below reads raw bytes first and parses only after verifying.
2. Reject stale timestamps.
Without a freshness check, a request captured once can be replayed against you forever. A five-minute window is a good default; compare in both directions so a clock skewed forward does not accept a far-future timestamp.
3. Compare in constant time.
A naive == on strings leaks timing information that can be used to reconstruct a valid
signature byte by byte. Use your language's constant-time comparison.
And after verification, deduplicate on x-auvral-event-id: retries reuse it, and a
handler that is not idempotent will process the same verification up to six times.
Working code
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.AUVRAL_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, signatureHeader, timestampHeader) {
if (!signatureHeader?.startsWith("v1=") || !timestampHeader) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestampHeader));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
const received = signatureHeader.slice("v1=".length);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
// express.raw, not express.json: the parsed body cannot be verified
app.post("/webhooks/auvral", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("x-auvral-signature"), req.get("x-auvral-timestamp"))) {
return res.sendStatus(401);
}
const eventId = req.get("x-auvral-event-id");
if (alreadyProcessed(eventId)) return res.sendStatus(200);
markProcessed(eventId);
const event = JSON.parse(rawBody);
// acknowledge first, work afterwards: the sender times out at 10s
res.sendStatus(200);
enqueue(event);
});If you use express.json() globally, mount express.raw() on this route specifically, or
configure a verify callback that stashes the raw buffer.
Testing your implementation
Use Send test event in the dashboard. It delivers a webhook.test event signed
exactly like a real one, so a handler that accepts it will accept real events.
Then check the failure paths, because those are the ones that matter:
- Change one character of the secret in your config. You should get
401, not200. - Replay a delivery with an old
x-auvral-timestamp. It should be rejected. - Deliver the same
x-auvral-event-idtwice. The second must be a no-op.
Every attempt, with its status code and your response body, is recorded on the webhook's page in the dashboard.
If verification fails
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body was parsed and re-serialized before you hashed it |
| Signature never matches | You hashed the body alone, without {timestamp}. in front |
| Signature never matches | You included the v1= prefix in the compared value |
| Works for some events, fails for others | A proxy or middleware is rewriting the body |
| Test event passes, real events fail | Different webhook, different secret: each webhook has its own |