How to verify webhook signatures (HMAC-SHA256) correctly

Updated

A webhook endpoint is a public URL that changes your database when it receives a request. Without verification, anyone who finds the URL can tell your system that an email bounced or an invoice was paid.

The scheme

Inboxili signs each delivery like this:

  1. Take the raw bytes of the request body.
  2. Compute HMAC-SHA256 with the webhook's secret as the key.
  3. Hex-encode the result and put it in the X-Inboxili-Signature header.

The event type is in X-Inboxili-Event. To verify, repeat steps 1 to 3 and compare the result with the header in constant time.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, signatureHex, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  if (!/^[0-9a-f]+$/i.test(signatureHex) || signatureHex.length !== expected.length * 2) return false;
  return timingSafeEqual(Buffer.from(signatureHex, "hex"), expected);
}

With Express, capture the body as bytes. If express.json() runs first, the raw bytes are gone:

app.post("/webhooks/inboxili", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("X-Inboxili-Signature") ?? "", process.env.INBOXILI_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // handle event...
  res.sendStatus(200);
});

The @inboxili/node package exports the same check as verifyWebhookSignature.

Python

import hashlib
import hmac


def verify(raw_body: bytes, signature_hex: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_hex.strip().lower())

In Flask use request.get_data() for the bytes before touching request.json. In Django use request.body. The inboxili package has verify_webhook_signature.

PHP

<?php
function verifySignature(string $rawBody, string $signatureHex, string $secret): bool
{
    return hash_equals(hash_hmac('sha256', $rawBody, $secret), strtolower(trim($signatureHex)));
}

$raw = file_get_contents('php://input');
if (!verifySignature($raw, $_SERVER['HTTP_X_INBOXILI_SIGNATURE'] ?? '', getenv('INBOXILI_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);

Inboxili\Webhook::verify in the PHP SDK does this check.

The mistakes that break it

| Mistake | Effect | |---|---| | Verifying parsed and re-serialised JSON | Whitespace and key order change, so the signature never matches | | Comparing with == or === | Leaks timing information | | Using a body that middleware already decoded | Same as the first row | | A trailing newline or proxy that rewrites the body | Signature mismatch. Verify as close to the edge as you can | | Logging the secret while debugging | Now it is not secret | | Returning 200 on failure | Attackers learn nothing, but you lose the alert. Return 401 and log it |

Replays

A valid signed request can be captured and sent again. The Inboxili signature covers only the body, so it carries no timestamp you can check. Defend by making the handler idempotent: key your processing on the message_id plus the event type, store which ones you have handled, and ignore repeats. That also protects you when Inboxili or your own infrastructure delivers an event twice.

async function handleOnce(req, res, event) {
  const key = `${req.get("X-Inboxili-Event")}:${event.message_id}`;
  if (!(await db.processedEvents.insertIfNew(key))) return res.sendStatus(200); // already handled
  // ...do the work...
  res.sendStatus(200);
}

Respond quickly

Do the minimum before answering: verify, record, return 2xx. Do slow work in a queue. A slow endpoint ties up the sender, and a non-2xx response marks the delivery as failed.

Frequently asked questions

Does the Inboxili signature include a timestamp?
No. The X-Inboxili-Signature header is the hex HMAC-SHA256 of the raw request body only. Protect against replays by making your handler idempotent.
Where do I find the webhook secret?
It belongs to the webhook you registered in the dashboard. Store it like a password.

Set up webhooks with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related