Webhook Signature Verification
Verifying the x-adjudon-signature header is the single most
error-prone integration step in any webhook flow. Most failures fall
into one of three categories: parsing the body before the bytes are
HMAC'd, comparing strings non-constant-time, or never checking the
signed timestamp for stale replays. This page is the recipe in four
languages plus the Adjudon-side mechanics that justify each step.
What each step defends against
| Step | Threat | Without this step |
|---|---|---|
| Read raw bytes | Body-parsing tampering | A middleware that reformats whitespace silently breaks the HMAC |
| Recompute HMAC | Forgery | An attacker who knows the URL but not the secret can submit any payload |
| Constant-time compare | Timing attack | Byte-position leakage lets an attacker recover a valid signature in O(n) probes |
| Timestamp window | Replay attack | A captured legitimate delivery is reusable indefinitely |
The four steps are independent. A handler that does three of them is broken in exactly the threat the missing step covers; the verifier never warns you which of the four it skipped.
What Adjudon signs
Adjudon HMACs the raw request body, byte-for-byte, with the secret you received once at webhook creation:
signature = HMAC_SHA256(secret, raw_body)
header = x-adjudon-signature: sha256=<hex(signature)>
The body is one JSON object the dispatcher serialises before signing:
{
"event": "trace.created",
"timestamp": "2026-05-06T10:14:22.317Z",
"data": { /* event-specific payload, PII-scrubbed */ }
}
The companion header x-adjudon-event carries the event name in
plain text for routing convenience. It is not part of the
signature; never decide trust from this header alone.
The four-step recipe
Every correct verifier does the same four steps in this order:
- Read the raw body bytes. Not the JSON-parsed object — the
raw
Content-Type: application/jsonbytes exactly as transmitted. Body parsers consume the bytes; once parsed, the original sequence is gone. - Recompute the HMAC.
HMAC_SHA256(secret, raw_body). Hex-encode the digest. - Compare in constant time.
crypto.timingSafeEqual/hmac.compare_digest/MessageDigest.isEqual. Never===or==. - Check the body's
timestamp. Reject if more than five minutes off the receiver's clock. This is the replay-attack guard; Adjudon does not enforce it server-side, so the receiver owns the policy.
Skip any step and the verifier is broken in a way that does not necessarily fail loudly — tests pass, attackers don't.
Node — Express
import express from "express";
import crypto from "node:crypto";
const app = express();
const TOLERANCE_MS = 5 * 60 * 1000;
app.post(
"/adjudon",
// Read raw bytes — DO NOT use express.json() before this route.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-adjudon-signature"]?.replace(/^sha256=/, "");
const expected = crypto
.createHmac("sha256", process.env.ADJUDON_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const sigBuf = Buffer.from(sig || "", "hex");
const expBuf = Buffer.from(expected, "hex");
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
if (Math.abs(Date.now() - new Date(event.timestamp).getTime()) > TOLERANCE_MS) {
return res.status(401).end();
}
// event.event, event.data — handle here
res.status(200).end();
}
);
import { Adjudon } from "@adjudon/node";
const adjudon = new Adjudon({ apiKey: process.env.ADJUDON_API_KEY });
// In your handler:
const event = adjudon.webhooks.constructEvent(
req.body, // raw bytes
req.headers["x-adjudon-signature"],
process.env.ADJUDON_WEBHOOK_SECRET,
{ tolerance: 300 } // seconds; default 300
);
// `event` is verified or `constructEvent` throws.
Python — FastAPI
import hmac, hashlib, json, os, time
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
TOLERANCE_S = 300
@app.post("/adjudon")
async def adjudon_webhook(request: Request):
raw = await request.body() # raw bytes, not parsed JSON
sig = request.headers.get("x-adjudon-signature", "").replace("sha256=", "")
expected = hmac.new(
os.environ["ADJUDON_WEBHOOK_SECRET"].encode(),
raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
raise HTTPException(401)
event = json.loads(raw)
sent_at = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
if abs(time.time() - sent_at.timestamp()) > TOLERANCE_S:
raise HTTPException(401)
return {"ok": True}
from adjudon import Adjudon
adjudon = Adjudon(api_key=os.environ["ADJUDON_API_KEY"])
# In your handler:
event = adjudon.webhooks.construct_event(
raw,
request.headers["x-adjudon-signature"],
os.environ["ADJUDON_WEBHOOK_SECRET"],
tolerance=300,
)
Java — Spring Boot
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
@PostMapping(value = "/adjudon", consumes = "application/json")
public ResponseEntity<String> adjudonWebhook(
@RequestBody byte[] raw,
@RequestHeader("x-adjudon-signature") String sigHeader) throws Exception {
String sig = sigHeader.replace("sha256=", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
System.getenv("ADJUDON_WEBHOOK_SECRET").getBytes(StandardCharsets.UTF_8),
"HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(raw));
// CONSTANT-TIME compare
if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) {
return ResponseEntity.status(401).build();
}
// Parse and check timestamp
var event = new ObjectMapper().readTree(raw);
Instant sent = Instant.parse(event.get("timestamp").asText());
if (Math.abs(Instant.now().toEpochMilli() - sent.toEpochMilli()) > 300_000) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok().build();
}
.NET — ASP.NET Core
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("adjudon")]
public class AdjudonWebhookController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Receive()
{
// Read RAW bytes — do not auto-bind to a DTO.
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
byte[] raw = ms.ToArray();
var sig = Request.Headers["x-adjudon-signature"]
.ToString().Replace("sha256=", "");
using var hmac = new HMACSHA256(
Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("ADJUDON_WEBHOOK_SECRET")!));
var expected = Convert.ToHexString(hmac.ComputeHash(raw)).ToLowerInvariant();
// CONSTANT-TIME compare
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(sig),
Encoding.UTF8.GetBytes(expected))) {
return Unauthorized();
}
var doc = System.Text.Json.JsonDocument.Parse(raw);
var sentAt = doc.RootElement.GetProperty("timestamp").GetDateTime();
if (Math.Abs((DateTime.UtcNow - sentAt).TotalSeconds) > 300) {
return Unauthorized();
}
return Ok();
}
}
Constant-time primitives by language
| Language | Constant-time API | Notes |
|---|---|---|
| Node | crypto.timingSafeEqual(buf1, buf2) | Throws if buffer lengths differ — check lengths first |
| Python | hmac.compare_digest(a, b) | Accepts strings or bytes; works on hex digests directly |
| Java | MessageDigest.isEqual(byte[], byte[]) | Documented constant-time since JDK 6u17 |
| .NET | CryptographicOperations.FixedTimeEquals(span1, span2) | Use over Enumerable.SequenceEqual for crypto |
| Go | subtle.ConstantTimeCompare(a, b) | Returns 1 on match, 0 otherwise |
| Ruby | OpenSSL.fixed_length_secure_compare(a, b) | Available since Ruby 3.0 |
Avoid the language's general string equality (==, ===,
String.equals, ==) on signature bytes — every one of them
short-circuits on the first mismatch and leaks the byte position
through timing. Two correct verifiers from the same codebase can
differ only by which string compare they use.
Common failure modes
401on every delivery. The middleware before your route is parsing the body and consuming the bytes. In Express, that meansapp.use(express.json())is mounted ahead of the webhook route; in FastAPI,Request.body()was already called once; in Spring, an interceptor logged or transformed the body. Mount the raw-body reader on the webhook path only.- Sometimes-correct signature. A non-constant-time comparison
(
===,==,String.equals) leaks byte-position differences via timing. Tests with a known-good signature pass; an attacker who controls millions of probes recovers the secret. Always use the language's constant-time primitive. - Stale events accepted. Without the
timestampwindow check, a captured legitimate delivery can be replayed indefinitely. The five-minute window is a convention; pick what your operational context tolerates and document it. - Trusting
x-adjudon-event. This header is convenience metadata routed before signature check. Never branch security-relevant logic on it; parse and trustevent.eventfrom the verified body instead.
Secret rotation
Webhook secrets are minted server-side at webhook creation and stored
on the Webhook document with select: false — the value is
never returned by any list or get endpoint. Rotation is a
delete-and-recreate today: create a new webhook with a new URL or a
new endpoint path, roll your handler, then delete the old webhook
once the new one is verifying cleanly. Side-by-side rotation with
zero-downtime overlap is on the roadmap.
See also
- Webhooks Overview — the delivery shape and retry schedule
- Webhook Events Catalog — per-event payload schemas
- Webhook Config API — the CRUD endpoints
- Authentication — secret-storage best practices