GitHub Webhook を受信する際に必要な署名検証(HMAC)、冪等(べきとう)性の担保、即レスポンス設計を、Fastifyで実装します。
本当は Stripe 連携を想定した内容を予定してましたが、CLI のインストールが必要だったりと準備が必要だったので、GitHub Webhook を使ってます...。
やっていること
- GitHub Webhook の
X-Hub-Signature-256を使った署名検証の実装 -
X-GitHub-Deliveryを使った重複配信の防止(冪等性) - Webhook ハンドラを軽く保つための「即ACK(Acknowledgement) → バックグラウンド処理」の設計
- ローカル環境で GitHub を使わずに検証する方法
技術スタック
- Node.js: v20+
- Fastify: v5.6.2
- TypeScript: v5.7.2
Webhookの3つの課題
Webhook は外部サービスがPOSTしてくるイベント通知なので、考慮すべき課題があります。
1. 改ざんのリスク
公開URLでWebhookを受ける場合、第三者が偽のリクエストを送る可能性がある。
→署名検証
2. 重複配信
ネットワーク障害やタイムアウトにより、同じイベントが複数回届くことがある。
→冪等性の担保
3. タイムアウト
Webhookの送り手(GitHub)は、一定時間内にレスポンスが返らないと「失敗」と判断して再送する。
→即座にACKを返し、重い処理は分離する
プロジェクトのセットアップ
依存関係のインストール
こちらにまとめます
npm install fastify fastify-raw-body dotenv
npm install -D typescript tsx @types/node
TypeScript設定
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"moduleDetection": "force",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
package.json
{
"name": "fastify-github-webhook",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"send:test": "tsx src/send-test.ts"
},
"dependencies": {
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"fastify-raw-body": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
実装:サーバー側
全体コード
import Fastify from "fastify";
import rawBody from "fastify-raw-body";
import crypto from "node:crypto";
import "dotenv/config";
const app = Fastify({ logger: true });
await app.register(rawBody, {
field: "rawBody", // request.rawBody に入る
global: false, // 必要なルートだけ有効化
encoding: "utf8",
runFirst: true,
});
type DeliveryStore = {
has: (id: string) => Promise<boolean>;
add: (id: string) => Promise<void>;
};
// デモ用:メモリ保存(本番はDB/Redis推奨)
const deliveries = new Set<string>();
const store: DeliveryStore = {
async has(id) {
return deliveries.has(id);
},
async add(id) {
deliveries.add(id);
},
};
function timingSafeEqualHex(a: string, b: string) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
function verifyGitHubSignature(
rawBody: string,
secret: string,
signature256?: string,
) {
if (!signature256) return false;
// GitHubの X-Hub-Signature-256 は "sha256=<hex>" 形式
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return timingSafeEqualHex(expected, signature256);
}
app.post(
"/webhook/github",
{
config: { rawBody: true }, // このルートだけ rawBody を有効化
},
async (request, reply) => {
const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "dev_secret_123";
const sig256 = request.headers["x-hub-signature-256"] as
| string
| undefined;
const deliveryId = request.headers["x-github-delivery"] as
| string
| undefined;
const eventName = request.headers["x-github-event"] as string | undefined;
const raw = (request as any).rawBody as string | undefined;
// 1) 署名検証(まず最初にやる)
if (!raw || !verifyGitHubSignature(raw, secret, sig256)) {
request.log.warn(
{ deliveryId, eventName },
"signature verification failed",
);
return reply.code(401).send({ ok: false });
}
// 2) 冪等性(Delivery IDで重複排除)
// GitHubの配信にはX-GitHub-Deliveryが付く
if (deliveryId && (await store.has(deliveryId))) {
request.log.info(
{ deliveryId, eventName },
"duplicate delivery ignored",
);
return reply.code(200).send({ ok: true, duplicate: true });
}
if (deliveryId) await store.add(deliveryId);
// 3) 受信は即ACK(Webhookの鉄則)
reply.code(200).send({ ok: true });
// 4) 重い処理は分離(ここから先はバックグラウンド)
void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
request.log.error(
{ err: e, deliveryId, eventName },
"event handling failed",
);
});
},
);
async function handleEvent(eventName: string, payload: any) {
// 例:push / pull_request / issues で分岐
switch (eventName) {
case "push":
// payload.ref / commits など
break;
case "pull_request":
// payload.action / pull_request.title など
break;
default:
break;
}
}
await app.listen({ port: 4100, host: "0.0.0.0" });
ポイント
1. raw bodyの取得
署名検証にはパース前の生データが必要です。fastify-raw-body プラグインで request.rawBody を取得します。
await app.register(rawBody, {
field: "rawBody",
global: false,
encoding: "utf8",
runFirst: true,
});
ルート単位で有効化するには config: { rawBody: true } を指定します。
2. 署名検証(HMAC-SHA256)
GitHubは X-Hub-Signature-256 ヘッダに sha256=<hex> 形式で署名を送ります。
function verifyGitHubSignature(
rawBody: string,
secret: string,
signature256?: string,
) {
if (!signature256) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return timingSafeEqualHex(expected, signature256);
}
タイミング攻撃対策として crypto.timingSafeEqual を使っています。
3. 冪等性の担保
X-GitHub-Delivery は各配信に付与されるユニークIDです。これをキーに重複を検出します。
if (deliveryId && (await store.has(deliveryId))) {
return reply.code(200).send({ ok: true, duplicate: true });
}
if (deliveryId) await store.add(deliveryId);
本番環境では Set ではなく、DBやRedisなど永続ストアを使いましょう。
4. 即ACK & 処理分離
検証と重複チェックが通ったら、すぐに200を返します。重い処理はバックグラウンドで実行します。
reply.code(200).send({ ok: true });
// ここから先はバックグラウンド
void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
request.log.error({ err: e, deliveryId, eventName }, "event handling failed");
});
ローカル検証用スクリプト
GitHub に実際に Webhook を設定しなくても、ローカルで署名付きリクエストを再現できます。
実際にやる場合はレポジトリをつくって、Webhook の追加を行ってください。
import crypto from "node:crypto";
import "dotenv/config";
const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "dev_secret_123";
const payload = {
action: "opened",
repository: { full_name: "yutowac/demo" },
pull_request: { title: "Test PR" },
};
const raw = JSON.stringify(payload);
const sig256 =
"sha256=" + crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
const deliveryId = crypto.randomUUID();
const res = await fetch("http://localhost:4100/webhook/github", {
method: "POST",
headers: {
"content-type": "application/json",
"x-hub-signature-256": sig256,
"x-github-delivery": deliveryId,
"x-github-event": "pull_request",
"user-agent": "GitHub-Hookshot/test",
},
body: raw,
});
console.log(res.status, await res.text());
実行方法
- サーバー起動:
npm run dev
- 別ターミナルでテスト送信:
npm run send:test
>> 200 {"ok":true} #成功時
冪等性のテスト
deliveryId はソースコードではランダムに生成していますが、固定値にして2回送ると検出します。
const deliveryId = "test-delivery-id-123";
npm run send:test
>> 200 {"ok":true,"duplicate":true}
補足
本番環境で変えたほうが良いことを補足します。
1. Delivery IDの永続化
メモリ上の Set ではなく、RDBやRedisに保存して、プロセス再起動後も重複検出できるようにします。
// 例: Redisを使う場合
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
const store: DeliveryStore = {
async has(id) {
return (await redis.get(`delivery:${id}`)) !== null;
},
async add(id) {
await redis.set(`delivery:${id}`, "1", { EX: 86400 }); // 24時間保持
},
};
2. ジョブキューの導入
handleEvent() 内で直接重い処理をせず、ジョブとして積みます。
import { Queue } from "bullmq";
const queue = new Queue("webhook-events");
void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
});
// 実際の処理はジョブキューで
await queue.add("process-event", {
eventName,
payload: JSON.parse(raw),
deliveryId,
});
3. スキーマ検証
eventName ごとに payload を検証します。
import { z } from "zod";
const pullRequestSchema = z.object({
action: z.string(),
pull_request: z.object({
title: z.string(),
number: z.number(),
}),
});
if (eventName === "pull_request") {
const validated = pullRequestSchema.parse(payload);
}
参考
