To verify a webhook signature, compute an HMAC over the exact raw request body, using the shared secret, and compare it to the signature header in constant time. Read the body as text before you parse it, because re-serializing parsed JSON changes bytes and breaks the hash. On DenkOps this is straightforward: the app is an always-on process, so there is no cold-start window on the endpoint, and the raw body is available before any parsing.
Why the raw body, not the parsed JSON?
Because the sender signed the bytes it sent, not your reconstruction of them. If you JSON.parse the body and re-stringify it, key order, whitespace, and number formatting can all shift, and the HMAC over that output will not match. So capture the raw text first, verify against it, and only then parse.
import { Hono } from "hono";
import { timingSafeEqual } from "node:crypto";
const app = new Hono();
app.post("/webhooks/provider", async (c) => {
const raw = await c.req.text(); // exact bytes, before parsing
const signature = c.req.header("x-signature") ?? "";
const secret = process.env.WEBHOOK_SECRET!;
const expected = new Bun.CryptoHasher("sha256", secret).update(raw).digest("hex");
if (!safeEqual(signature, expected)) {
return c.json({ error: "bad signature" }, 401);
}
const event = JSON.parse(raw); // safe to parse now
// ... handle event ...
return c.json({ ok: true });
});
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
export default app;Why compare in constant time?
A plain === on strings can return early on the first mismatched character, and the timing difference leaks how much of a forged signature was correct. timingSafeEqual always compares the full length, so an attacker learns nothing from how long the check took. Guard the length first, because timingSafeEqual throws on buffers of different sizes; unequal length already means the signature is wrong.
How does auth by default fit a public webhook?
A webhook sender is an outside service. It does not have your app's API key, so the endpoint has to accept unauthenticated requests. That is fine, as long as the signature check is your real gate.
DenkOps keeps auth by default on every route, then lets you open specific paths. You set the public paths after deploy, not in a config file: from your coding agent, say something like "make /webhooks/stripe public", or set the list in the dashboard. Under the hood that records the path so the platform's Bearer check steps aside for it while every other route stays locked.
Now the sender reaches the handler without a key, the rest of your app stays locked behind the API key, and the HMAC verifies who actually sent the payload. The signing secret is a write-only env var: your code reads it at run time, and nobody can read it back out. Reject anything that fails the check with a 401 before you do any work.
Why does an always-on process matter here?
Two reasons. First, there is no cold start on the endpoint, so a burst of webhook deliveries does not queue behind a process spinning up, and senders that retry on slow responses do not double-fire. Second, the raw body is right there in the request; you do not fight a platform that parses or buffers it out from under you before your handler runs. Reads land in the code versus I/O traces so you can see verification cost separately from downstream work. This is the same shape as a webhook your agent can query: receive, verify, store, then let the agent read it. The Bun and Hono webhook guide has a full walkthrough; the docs cover public paths.