Webhook の署名を検証する3 分で読めます
Node.js で Webhook の署名を検証する
Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。
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 パーサーが先に登録されていると、署名されたバイト列はミドルウェアでオブジェクトに解析されます。
- そのオブジェクトを再びシリアライズすると別の文字列になるので、署名は一致せず、ハンドラーは 400 を返します。
- Webhook のルートに raw パーサーを置けば、ハンドラーは送られたとおりのバイト列を Buffer として受け取ります。
- そのバイト列を検証し、その後で初めて解析して、204 を返します。
Express
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
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 を投げます。
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 を直接ハッシュするので、文字列のエンコードを気にする必要はありません。
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 ファイルを直接実行できます。
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 以降のオプション名を使っています。
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 を記録すると、途中でクラッシュしたときにリトライもスキップされます。リトライさせたいなら、処理が成功してから記録してください。
動作を確認する
node --testを実行します。4 つのケースすべてが通ります。- たとえば
cloudflared tunnel --url http://localhost:3000などでサーバーを公開https:URL に公開し、ダッシュボードの Webhooks に追加します。 IKIFORM_WEBHOOK_SECRETをエンドポイントのwhsec_シークレットに設定し、サーバーを再起動します。- Send test を選びます。配信ログに
204が表示されます。 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 のルートで上限を引き上げてください |
よくある質問
Webhook のルートに express.raw({ type: "application/json" }) をミドルウェアとして追加すると、req.body が Buffer になります。グローバルな express.json() より前にルートを登録してください。そうしないと JSON パーサーが先に動きます。
バイト長が同じ 2 つのバッファが必要で、そうでなければ RangeError を投げます。先に長さを比較し、一致しなければ署名の検証失敗として扱ってください。
はい。Ikiform は Standard Webhooks に準拠しているので、ダッシュボードの whsec_ シークレットで new Webhook(secret).verify(body, headers) が動きます。チェックに失敗すると WebhookVerificationError を投げます。
Node 20 以降と、Cloudflare Workers、Deno、Bun です。verifyWebhook は Web Crypto API だけを使います。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com