Auvral

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: 1787761964

The 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

webhook.js
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, not 200.
  • Replay a delivery with an old x-auvral-timestamp. It should be rejected.
  • Deliver the same x-auvral-event-id twice. 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

SymptomLikely cause
Signature never matchesThe body was parsed and re-serialized before you hashed it
Signature never matchesYou hashed the body alone, without {timestamp}. in front
Signature never matchesYou included the v1= prefix in the compared value
Works for some events, fails for othersA proxy or middleware is rewriting the body
Test event passes, real events failDifferent webhook, different secret: each webhook has its own

On this page