Skip to main content

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

StepThreatWithout this step
Read raw bytesBody-parsing tamperingA middleware that reformats whitespace silently breaks the HMAC
Recompute HMACForgeryAn attacker who knows the URL but not the secret can submit any payload
Constant-time compareTiming attackByte-position leakage lets an attacker recover a valid signature in O(n) probes
Timestamp windowReplay attackA 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:

Signed body shape
{
"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:

  1. Read the raw body bytes. Not the JSON-parsed object — the raw Content-Type: application/json bytes exactly as transmitted. Body parsers consume the bytes; once parsed, the original sequence is gone.
  2. Recompute the HMAC. HMAC_SHA256(secret, raw_body). Hex-encode the digest.
  3. Compare in constant time. crypto.timingSafeEqual / hmac.compare_digest / MessageDigest.isEqual. Never === or ==.
  4. 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

Node — manual verification
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();
}
);
Node — SDK helper
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

Python — manual verification
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}
Python — SDK helper
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

Java — manual verification
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

.NET — manual verification
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

LanguageConstant-time APINotes
Nodecrypto.timingSafeEqual(buf1, buf2)Throws if buffer lengths differ — check lengths first
Pythonhmac.compare_digest(a, b)Accepts strings or bytes; works on hex digests directly
JavaMessageDigest.isEqual(byte[], byte[])Documented constant-time since JDK 6u17
.NETCryptographicOperations.FixedTimeEquals(span1, span2)Use over Enumerable.SequenceEqual for crypto
Gosubtle.ConstantTimeCompare(a, b)Returns 1 on match, 0 otherwise
RubyOpenSSL.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

  • 401 on every delivery. The middleware before your route is parsing the body and consuming the bytes. In Express, that means app.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 timestamp window 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 trust event.event from 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