Verify webhook signatures

Verify webhook signatures6 min read

Verify webhook signatures in Node.js

Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.

Preet SutharFounder, Ikiform

Get the request body as raw bytes before any JSON parser runs, then pass it with the headers and your whsec_ secret to verifyWebhook from @ikiform/sdk/webhooks. In Express that means express.raw() on the webhook route; in Fastify, a string content-type parser scoped to that route. Without the SDK, node:crypto's createHmac and timingSafeEqual do the same in about 20 lines.

This page covers long-running Node servers: Express and Fastify, plus a dependency-free version you can unit test. For route handlers in Next.js, Astro and other frameworks, see the overview; for a Cloudflare Worker with storage, see your own server.

What's different in Node

Node frameworks are helpful with JSON. Register express.json() or use Fastify's defaults and every handler gets req.body as an object. By the time your webhook code runs, the bytes Ikiform signed are gone, and JSON.stringify(req.body) produces different ones: other spacing, other escaping of characters such as / or emoji. The fix is about where you get the body, not how you hash it.

A webhook body passing through a global JSON parser compared with a raw parser on the routeglobal express.json() firstexpress.raw() on the webhook routeraw byteswhat IkiformsignedJSON.parsein middlewareJSON.stringifydifferentbytes400no_matching_signatureraw byteswhat IkiformsignedBufferuntouchedverify, thenparse204A webhook body passing through a global JSON parser compared with a raw parser on the routeglobal express.json() firstexpress.raw() on the webhook routeraw byteswhat Ikiform signedJSON.parsein middlewareJSON.stringifydifferent bytes400no_matching_signatureraw byteswhat Ikiform signedBufferuntouchedverify, then parse204
  1. With a global JSON parser registered first, the signed bytes are parsed into an object in middleware.
  2. Serializing that object again gives a different string, so the signature can't match and the handler answers 400.
  3. With a raw parser on the webhook route, the handler receives the exact bytes as a Buffer.
  4. It verifies those bytes, parses them only afterwards and answers 204.
Order matters. Express runs middleware in the order you register it, so the webhook route goes first.

Express

server.js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json", limit: "300kb" }), async (req, res) => {
  try {
    const event = await verifyWebhook({
      payload: req.body.toString("utf8"),
      headers: req.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET,
    });
    if (event.type === "submission.created") {
      console.info("submission", event.data.submission_id, event.data.fields);
    }
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
    throw error;
  }
});

app.use(express.json());

app.listen(3000);
  • express.raw({ type: "application/json" }) on this route only gives req.body as a Buffer. The global express.json() registered after it never sees this request, because the route has already answered.
  • The limit sits above Ikiform's 256 KB payload cap. Express's default of 100 KB would refuse a large submission with 413.
  • verifyWebhook takes Node's req.headers as-is; it looks headers up case-insensitively and checks the timestamp, every v1 signature and the JSON.
  • In Express 5, an error thrown from an async handler goes to the error middleware, so the rethrow is safe.

Fastify

server.js
import Fastify from "fastify";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = Fastify({ logger: true });

app.register(async (webhooks) => {
  webhooks.addContentTypeParser(
    "application/json",
    { parseAs: "string", bodyLimit: 300 * 1024 },
    (_request, body, done) => done(null, body),
  );

  webhooks.post("/webhooks/ikiform", async (request, reply) => {
    try {
      const event = await verifyWebhook({
        payload: request.body,
        headers: request.headers,
        secret: process.env.IKIFORM_WEBHOOK_SECRET,
      });
      request.log.info({ type: event.type, id: event.id }, "ikiform webhook");
      return reply.code(204).send();
    } catch (error) {
      if (error instanceof WebhookVerificationError) return reply.code(400).send(error.reason);
      throw error;
    }
  });
});

await app.listen({ port: 3000, host: "0.0.0.0" });

Fastify parses application/json itself. Registering a parser with parseAs: "string" inside register replaces it for routes in that plugin only, thanks to encapsulation, so the rest of your API still gets parsed objects. request.body is then the exact string Ikiform sent.

With the standardwebhooks package

If you'd rather use the spec's reference library, npm install standardwebhooks. Its Webhook class strips the whsec_ prefix itself and accepts a Buffer body. It's synchronous and throws WebhookVerificationError with a message:

server.js
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";

const webhook = new Webhook(process.env.IKIFORM_WEBHOOK_SECRET);
const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = webhook.verify(req.body, req.headers);
    console.info(event.type, event.id);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
    throw error;
  }
});
@ikiform/sdk uses Web Crypto only, so it needs Node 20 or later. The standardwebhooks package bundles its own SHA-256 and also runs on older versions.

With node:crypto and a test

No dependency, and a return value that says why a request failed. It hashes the raw Buffer directly, so there's no question of string encoding:

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

export type VerifyFailure =
  | "missing_headers"
  | "invalid_timestamp"
  | "timestamp_too_old"
  | "timestamp_too_new"
  | "no_matching_signature";

type Headers = Record<string, string | string[] | undefined>;

const TOLERANCE_SECONDS = 5 * 60;

function header(headers: Headers, name: string) {
  const value = headers[name];
  return Array.isArray(value) ? value.join(" ") : value;
}

export function verifySignature(
  rawBody: Buffer | string,
  headers: Headers,
  secret: string,
  now = Date.now(),
): VerifyFailure | null {
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) return "missing_headers";
  if (!/^\d{1,12}$/.test(timestamp)) return "invalid_timestamp";

  const age = Math.floor(now / 1000) - Number(timestamp);
  if (age > TOLERANCE_SECONDS) return "timestamp_too_old";
  if (age < -TOLERANCE_SECONDS) return "timestamp_too_new";

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",", 2);
    if (version !== "v1" || !value) continue;
    const received = Buffer.from(value, "base64");
    if (received.length === expected.length && timingSafeEqual(received, expected)) return null;
  }
  return "no_matching_signature";
}
  • timingSafeEqual throws when the two buffers differ in length, so the length check comes first. A wrong-length signature is not a match.
  • createHmac(...).update() twice hashes the prefix and the body as one stream, the same as hashing them joined.
  • Node lowercases incoming header names, so headers["webhook-id"] works on req.headers directly.

And a test with the test vector. Run it with node --test; Node 22.18 and later run TypeScript files directly:

verify.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifySignature } from "./verify.ts";

const secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=";
const rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=";
const body =
  '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}';
const headers = {
  "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "webhook-timestamp": "1792300000",
  "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
};
const now = 1792300000 * 1000 + 30_000;

test("accepts the test vector", () => {
  assert.equal(verifySignature(Buffer.from(body), headers, secret, now), null);
});

test("accepts either secret during a roll", () => {
  const both = { ...headers, "webhook-signature": `${headers["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` };
  assert.equal(verifySignature(body, both, secret, now), null);
  assert.equal(verifySignature(body, both, rolled, now), null);
});

test("rejects a re-serialized body", () => {
  const pretty = JSON.stringify(JSON.parse(body), null, 2);
  assert.equal(verifySignature(pretty, headers, secret, now), "no_matching_signature");
});

test("rejects a stale timestamp", () => {
  assert.equal(verifySignature(body, headers, secret, now + 10 * 60_000), "timestamp_too_old");
});

The fixed now keeps the 2026 timestamp inside the 5-minute window. The pretty-printed case is the one that catches a parser sneaking in later, so keep it even when the others feel obvious.

Skipping repeats

A verified delivery can still arrive twice: if your handler finishes but the answer is lost or takes longer than 15 seconds, Ikiform retries with the same webhook-id. In a Node service that already has Redis, one SET with the NX condition remembers each id. This uses the option names of node-redis 5 and later:

deliveries.js
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

export async function firstDelivery(deliveryId) {
  const stored = await redis.set(`ikiform:delivery:${deliveryId}`, "1", {
    condition: "NX",
    expiration: { type: "EX", value: 4 * 24 * 60 * 60 },
  });
  return stored === "OK";
}

Call await firstDelivery(event.id) after verifying and before doing any work; if it returns false, answer 204 and stop. The 4-day expiry outlasts Ikiform's retry window of about three days, so a late retry is still recognized. Without Redis, a unique column in your database does the same job. Recording the id first means a crash halfway through skips the retry too; if you would rather retry, record it only after the work succeeds.

Check it works

  1. Run node --test. All four cases pass.
  2. Expose the server on a public https: URL, for example with cloudflared tunnel --url http://localhost:3000, and add it under Webhooks in the dashboard.
  3. Set IKIFORM_WEBHOOK_SECRET to the endpoint's whsec_ secret and restart the server.
  4. Choose Send test. The delivery log shows 204.
  5. Move app.use(express.json()) above the webhook route and send another test. It fails with 400 no_matching_signature, which proves the test would catch that mistake. Move it back.

Gotchas

ProblemCause and fix
req.body is undefined or {}The raw parser's type didn't match the request's Content-Type, so it skipped the body. Ikiform sends application/json
TypeScript says a Buffer isn't assignable to stringverifyWebhook takes the body as a string. Pass req.body.toString("utf8")
Works locally, fails on a serverless hostThe platform parsed the body for you. On the Next.js Pages Router, set bodyParser: false in the route's config and read the stream yourself
RangeError: Input buffers must have the same byte lengthtimingSafeEqual with a malformed signature. Check lengths before comparing
413 Payload Too Large in the delivery logThe body parser's limit is below 256 KB. Raise it on the webhook route

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.