Webhook の署名を検証する

Webhook の署名を検証する3 分で読めます

Node.js で Webhook の署名を検証する

Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。

Preet SutharIkiform 創業者

JSON パーサーが動く前に、リクエストのボディを生のバイト列として取得し、ヘッダーと whsec_ のシークレットと一緒に @ikiform/sdk/webhooks の verifyWebhook に渡します。Express なら Webhook のルートに express.raw() を、Fastify ならそのルートに限定した文字列のコンテンツタイプパーサーを使います。SDK を使わなくても、node:crypto の createHmac と timingSafeEqual で 20 行ほどで同じことができます。

このページでは、常駐する Node サーバーを扱います。Express と Fastify に加え、単体テストできる依存なしの版も示します。Next.js、Astro などのフレームワークのルートハンドラーは概要を、ストレージ付きの Cloudflare Worker は自分のサーバーを参照してください。

Node ならではの違い

Node のフレームワークは JSON を親切に扱ってくれます。express.json() を登録するか Fastify の既定のままにすれば、どのハンドラーも req.body をオブジェクトとして受け取ります。Webhook のコードが動く頃には Ikiform が署名したバイト列は失われていて、JSON.stringify(req.body) は別のバイト列を作ります。空白の入れ方も、/ や絵文字などの文字のエスケープも違います。解決策はハッシュの取り方ではなく、ボディをどこで取得するかにあります。

グローバルな JSON パーサーを通る Webhook のボディと、ルート上の raw パーサーを通るボディの比較グローバルな express.json() が先Webhook のルートに express.raw()生のバイト列Ikiformが署名したものJSON.parseミドルウェアでJSON.stringify別のバイト列400no_matching_signature生のバイト列Ikiformが署名したものBuffer手つかず検証してから解析204グローバルな JSON パーサーを通る Webhook のボディと、ルート上の raw パーサーを通るボディの比較グローバルな express.json() が先Webhook のルートに express.raw()生のバイト列Ikiform が署名したものJSON.parseミドルウェアでJSON.stringify別のバイト列400no_matching_signature生のバイト列Ikiform が署名したものBuffer手つかず検証してから解析204
  1. グローバルな JSON パーサーが先に登録されていると、署名されたバイト列はミドルウェアでオブジェクトに解析されます。
  2. そのオブジェクトを再びシリアライズすると別の文字列になるので、署名は一致せず、ハンドラーは 400 を返します。
  3. Webhook のルートに raw パーサーを置けば、ハンドラーは送られたとおりのバイト列を Buffer として受け取ります。
  4. そのバイト列を検証し、その後で初めて解析して、204 を返します。
順番が重要です。Express はミドルウェアを登録した順に実行するので、Webhook のルートを最初に置きます。

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" }) を付けると、req.body が Buffer になります。その後に登録したグローバルな express.json() は、ルートがすでに応答しているので、このリクエストを見ることはありません。
  • limit は Ikiform のペイロード上限 256 KB より大きくしています。Express の既定値 100 KB では、大きな送信データが 413 で拒否されてしまいます。
  • verifyWebhook は Node の req.headers をそのまま受け取ります。ヘッダーを大文字小文字を区別せずに探し、タイムスタンプ、すべての v1 署名、JSON を確認します。
  • Express 5 では、async ハンドラーから投げられたエラーはエラーミドルウェアに渡るので、再スローしても安全です。

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 は application/json を自分で解析します。register の中で parseAs: "string" のパーサーを登録すると、カプセル化のおかげでそのプラグイン内のルートだけでパーサーが置き換わるので、API の残りの部分は引き続き解析済みのオブジェクトを受け取ります。このとき request.body は Ikiform が送ったとおりの文字列です。

standardwebhooks パッケージを使う

仕様のリファレンスライブラリを使いたいなら、npm install standardwebhooks を実行します。その Webhook クラスは whsec_ プレフィックスを自分で取り除き、Buffer のボディも受け付けます。同期的に動作し、メッセージ付きの WebhookVerificationError を投げます。

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 は Web Crypto だけを使うので、Node 20 以降が必要です。standardwebhooks パッケージは独自の SHA-256 を同梱しているので、古いバージョンでも動きます。

node:crypto とテストを使う

依存なしで、リクエストが失敗した理由を返り値で示します。生の Buffer を直接ハッシュするので、文字列のエンコードを気にする必要はありません。

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 は 2 つのバッファの長さが違うと例外を投げるので、先に長さを確認します。長さの違う署名は一致しないものとして扱います。
  • createHmac(...).update() を 2 回呼ぶと、プレフィックスとボディを 1 つのストリームとしてハッシュします。つないでからハッシュするのと同じ結果です。
  • Node は受け取ったヘッダー名を小文字にするので、req.headers で直接 headers["webhook-id"] が使えます。

テストベクターを使ったテストも用意します。node --test で実行します。Node 22.18 以降は TypeScript ファイルを直接実行できます。

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");
});

固定した now によって、2026 年のタイムスタンプが 5 分の範囲内に収まります。整形された(pretty-print)ケースこそ、後からパーサーが紛れ込んだときに気づかせてくれるものなので、他のケースが当たり前に思えても残しておいてください。

重複をスキップする

検証済みの配信でも 2 回届くことがあります。ハンドラーの処理が終わっても、応答が失われたり 15 秒以上かかったりすると、Ikiform は同じ webhook-id でリトライします。すでに Redis がある Node のサービスなら、NX 条件付きの SET 1 回で各 ID を記録できます。これは node-redis 5 以降のオプション名を使っています。

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";
}

検証の後、何か処理をする前に await firstDelivery(event.id) を呼びます。false が返ったら 204 を返して終わります。4 日の有効期限は Ikiform のリトライ期間(約 3 日)より長いので、遅れて届いたリトライも認識できます。Redis がなければ、データベースのユニーク列で同じことができます。先に ID を記録すると、途中でクラッシュしたときにリトライもスキップされます。リトライさせたいなら、処理が成功してから記録してください。

動作を確認する

  1. node --test を実行します。4 つのケースすべてが通ります。
  2. たとえば cloudflared tunnel --url http://localhost:3000 などでサーバーを公開 https: URL に公開し、ダッシュボードの Webhooks に追加します。
  3. IKIFORM_WEBHOOK_SECRET をエンドポイントの whsec_ シークレットに設定し、サーバーを再起動します。
  4. Send test を選びます。配信ログに 204 が表示されます。
  5. app.use(express.json()) を Webhook のルートより上に移動して、もう一度テストを送ります。400 no_matching_signature で失敗し、テストがその間違いを検出できることがわかります。元に戻してください。

ハマりどころ

問題原因と対処
req.body が undefined や {} になるraw パーサーの type がリクエストの Content-Type と一致せず、ボディが読み飛ばされています。Ikiform は application/json を送ります
TypeScript で Buffer は string に代入できないと言われるverifyWebhook はボディを文字列で受け取ります。req.body.toString("utf8") を渡してください
ローカルでは動くが、サーバーレスのホストで失敗するプラットフォームがボディを解析しています。Next.js の Pages Router では、ルートの config で bodyParser: false を設定し、自分でストリームを読んでください
RangeError: Input buffers must have the same byte length形式の崩れた署名で timingSafeEqual を呼んでいます。比較前に長さを確認してください
配信ログに 413 Payload Too Large が出るボディパーサーの上限が 256 KB を下回っています。Webhook のルートで上限を引き上げてください

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com