Blog · 2026-08-20

Verify webhook signatures the right way

Compute the HMAC over the raw request body before parsing, in an always-on process, and compare it in constant time.

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.

FAQ

Why verify against the raw body instead of parsed JSON?

The sender signs the exact bytes it transmits. Parsing and re-serializing can change whitespace, key order, or number formatting, so the HMAC over the reparsed data will not match. Read the body as text, verify, then parse.

How do I let a webhook through auth by default?

Mark the endpoint as a public path after you deploy, either from your coding agent ("make /webhooks/stripe public") or in the dashboard. The platform's API-key check steps aside for that path while the rest of your app stays protected. The webhook's own signature check becomes the gate for that route.

What stops timing attacks on the signature check?

Compare with a constant-time function such as `timingSafeEqual` instead of `===`, which can return early on the first mismatch. Check that the two values are the same length first, since a length mismatch already proves the signature is invalid.

Ship it yourself: bunx denkops deploy or say "deploy on DenkOps" from your coding agent.

Start on DenkOps →

← A finance connector that reads, carefully · Run agency connectors, one workspace per client →