Engineering3 min read

Webhooks you can actually verify

Ed25519 signatures, a public JWKS, and a key id that makes rotation need no coordination with you.

Anyone can POST to a public URL. Verification is the whole difference between a delivery and somebody who guessed your endpoint.

#Why not a shared secret

The common design is an HMAC over the body with a secret both sides hold. It works, and it has two properties worth being uncomfortable about.

The first is that both sides can forge a delivery. If our copy of your secret leaks, an attacker can sign events that are indistinguishable from ours — and so can anyone who has read your side of it. The thing that proves a message came from us is a thing you also have.

The second is that rotation is a conversation. Changing the secret means telling every customer, coordinating a window where both are accepted, and hoping nobody misses the note.

#What arrives instead

POST /hooks/auray HTTP/1.1
User-Agent: Auray-Webhooks/1
Auray-Event: job.succeeded
Auray-Signature: t=1757000482,v2=Zm9vYmFy…,kid=9f2c…
Content-Type: application/json

Auray-Signature is three parts: t, the Unix second the delivery was signed; v2, an Ed25519 signature in base64url; and kid, which public key signed it.

The signed message is the timestamp, a full stop, and the raw request body:

t + "." + rawBody

Read the body as bytes before any JSON parsing. Re-serialising changes it, and the signature will not match — which is the single most common reason a first verification attempt fails.

One key signs deliveries for every account, so a valid signature says the delivery came from us, not that it was meant for you. The body says that: it carries account, the id GET /v1/me returns, inside what the signature covers. Compare it with yours. (Updated 2026-09-23 — until then the body named no account, and this snippet stopped at the signature.)

Verifying, in Node
import { createPublicKey, verify } from "node:crypto";

const JWKS_URL = "https://api.auray.ai/.well-known/jwks.json";
let jwks = await fetch(JWKS_URL).then((r) => r.json());
let fetchedAt = Date.now();

// Yours: the `id` that GET /v1/me returns.
const ACCOUNT_ID = process.env.AURAY_ACCOUNT_ID;

// A key you have not seen: refetch the set once (at most once a minute) before verifying.
export async function refreshKeys(header) {
  const kid = /(?:^|,)kid=([^,]+)/.exec(header)?.[1];
  if (!kid || jwks.keys.some((k) => k.kid === kid) || Date.now() - fetchedAt < 60_000) return;
  jwks = await fetch(JWKS_URL).then((r) => r.json());
  fetchedAt = Date.now();
}

// Synchronous on purpose: it returns true or false, never a promise.
export function verifyDelivery(header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const jwk = jwks.keys.find((k) => k.kid === parts.kid);
  if (!jwk) return false;

  // Reject anything older than five minutes, so a captured delivery cannot be replayed later.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  const signed = verify(
    null, // Ed25519 signs the whole message; there is no prehash
    Buffer.from(`${parts.t}.${rawBody}`, "utf8"),
    createPublicKey({ key: jwk, format: "jwk" }),
    Buffer.from(parts.v2, "base64url"),
  );

  // The signature says we sent it; `account`, inside what it covers, says it was sent to you.
  return signed && JSON.parse(rawBody).account === ACCOUNT_ID;
}

// In your handler: refresh first (it does nothing unless the kid is new), then verify.
const header = request.headers.get("auray-signature") ?? "";
await refreshKeys(header);
if (!verifyDelivery(header, rawBody)) return new Response(null, { status: 401 });

The timestamp and the account are inside the signed message, so neither can be edited to get past these checks.

#The part that makes rotation invisible

kid is an RFC 7638 thumbprint — a hash of the key itself, not a name somebody assigned. That is what removes the conversation: the next key is published at the same JWKS at least 24 hours before it signs anything, new deliveries are then signed with it while the old key stays published for another 36 hours and 25 minutes, and a verifier that looks the key up by kid — and fetches the set again when it meets one it does not hold — follows along without being told anything. Every attempt is signed as it is sent, with the key current at that moment, retries included, so the only deliveries that carry the old key are ones sent before the switch. (Updated after this was first published: the set could then publish only one key, and this paragraph described a rotation the code could not do. The refetch is its own refreshKeys step, awaited before verifyDelivery, which stays synchronous: a verifier that became async would hand an unchanged if (!verifyDelivery(…)) a promise, which is always truthy, and let every delivery through. Updated again: this said the old key outlived every retry, which stopped being true when a paused endpoint began keeping its events for days and resuming their retries once it is re-verified; the wait is the retry ladder's span and the signature window, and it is enough because a retry is signed afresh.)

Key by kid, not by position

keys[0] works right up until the day of a rotation, and then fails on every delivery. It is the only way to get this wrong that still passes a test written today.

#Two expectations of your endpoint

Answer 2xx quickly, then do the work. A slow endpoint is retried, and a retried delivery is a duplicate you now have to handle.

Expect duplicates anyway. Delivery is at-least-once; key your handling on the job id rather than on having received the event exactly once — after the account check, because a job id is unique inside one account and nowhere else. And do not order by arrival — use the job's own status, which is the thing that is actually true.

#Before a real job depends on it

A newly registered endpoint receives a challenge delivery, signed exactly like a real one and naming its account the same way. Verifying it — account included — proves both that the endpoint is yours and that your verification code works, which is a better time to find out than the first time something you care about finishes. A challenge naming another account is somebody registering your URL: do not echo its nonce.

Full detail in Webhooks.

Filed under Engineering on All posts

Build on what you just read

Open the app to try the generators, or read the API reference and call them from your own code — one key, one balance of credits.