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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/webhook-signature-verification/nodejs

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

このページでは、常駐する Node サーバーを扱います。Express と Fastify に加え、単体テストできる依存なしの版も示します。Next.js、Astro などのフレームワークのルートハンドラーは[概要](https://ikiform.com/ja/guides/webhook-signature-verification.md#implementation)を、ストレージ付きの Cloudflare Worker は[自分のサーバー](https://ikiform.com/blog/form-submissions-to-your-own-server.md)を参照してください。

## Node ならではの違い

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

図: グローバルな JSON パーサーを通る Webhook のボディと、ルート上の raw パーサーを通るボディの比較

1. グローバルな JSON パーサーが先に登録されていると、署名されたバイト列はミドルウェアでオブジェクトに解析されます。
2. そのオブジェクトを再びシリアライズすると別の文字列になるので、署名は一致せず、ハンドラーは 400 を返します。
3. Webhook のルートに raw パーサーを置けば、ハンドラーは送られたとおりのバイト列を Buffer として受け取ります。
4. そのバイト列を検証し、その後で初めて解析して、204 を返します。

順番が重要です。Express はミドルウェアを登録した順に実行するので、Webhook のルートを最初に置きます。

## Express

`server.js`:

```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`:

```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`:

```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`:

```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"]` が使えます。

[テストベクター](https://ikiform.com/ja/guides/webhook-signature-verification.md#test-vector)を使ったテストも用意します。`node --test` で実行します。Node 22.18 以降は TypeScript ファイルを直接実行できます。

`verify.test.ts`:

```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`:

```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 のルートで上限を引き上げてください |

## よくある質問

### Express で Webhook の生のボディを取得するにはどうすればよいですか？

Webhook のルートに express.raw({ type: "application/json" }) をミドルウェアとして追加すると、req.body が Buffer になります。グローバルな express.json() より前にルートを登録してください。そうしないと JSON パーサーが先に動きます。

### crypto.timingSafeEqual が例外を投げるのはなぜですか？

バイト長が同じ 2 つのバッファが必要で、そうでなければ RangeError を投げます。先に長さを比較し、一致しなければ署名の検証失敗として扱ってください。

### Ikiform で standardwebhooks の npm パッケージを使えますか？

はい。Ikiform は Standard Webhooks に準拠しているので、ダッシュボードの whsec_ シークレットで new Webhook(secret).verify(body, headers) が動きます。チェックに失敗すると WebhookVerificationError を投げます。

### @ikiform/sdk は Webhook でどの Node のバージョンに対応していますか？

Node 20 以降と、Cloudflare Workers、Deno、Bun です。verifyWebhook は Web Crypto API だけを使います。

## その他のガイド

- [Webhook の署名を検証する](https://ikiform.com/ja/guides/webhook-signature-verification.md): フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。
- [Python](https://ikiform.com/ja/guides/webhook-signature-verification/python.md): Flask、FastAPI、Django で Ikiform の Webhook 署名を検証する方法。standardwebhooks パッケージか標準ライブラリを使い、pytest のテストも付けます。
- [Go の net/http](https://ikiform.com/ja/guides/webhook-signature-verification/go.md): Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
- [Ruby](https://ikiform.com/ja/guides/webhook-signature-verification/ruby.md): Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。
- [PHP](https://ikiform.com/ja/guides/webhook-signature-verification/php.md): Ikiform の Webhook 署名を、素の PHP では hash_hmac と hash_equals で、Laravel では Standard Webhooks ライブラリと CSRF の除外で検証します。
