GitHub AppのWebhookをNext.js Route Handlersで安全に処理する実装パターン
はじめに
GitHub AppやGitHub Actions連携ツールを開発する際、避けて通れないのが Webhook の処理です。 しかし、単にエンドポイントを開放するだけでは、「なりすましリクエスト」 や 「リトライによる二重実行」 といった問題に対処できません。
本記事では、Next.js (App Router) と Supabase を使い、セキュアかつ冪等性(Idempotency)を担保したWebhookハンドラ を実装する方法を解説します。
前提となる技術スタック
- Framework: Next.js 14 (App Router)
- Database: Supabase (PostgreSQL)
- Deploy: Vercel
1. 署名検証(Verify Signature)の実装
GitHubからのWebhookリクエストには、必ず X-Hub-Signature-256 というヘッダーが付与されています。これは「Webhook Secret」を使ってペイロードをハッシュ化したものです。
これを受け取り側で再計算し、一致するか確認することで、「本当にGitHubから送られてきた正しいリクエストか」 を検証します。
Next.jsのRoute Handlerで実装する場合、Web Crypto API を使用します。
import { NextResponse } from 'next/server';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET!;
// 署名を検証する関数
async function verifySignature(req: Request, payload: string) {
const signature = req.headers.get('x-hub-signature-256');
if (!signature) return false;
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
const digest = Buffer.from(
'sha256=' + hmac.update(payload).digest('hex'),
'utf8'
);
const checksum = Buffer.from(signature, 'utf8');
// タイミング攻撃を防ぐために crypto.timingSafeEqual を使用
return (
checksum.length === digest.length &&
crypto.timingSafeEqual(digest, checksum)
);
}
export async function POST(req: Request) {
// rawBodyを取得するためにtext()を使用
const bodyText = await req.text();
// 1. セキュリティチェック
const isValid = await verifySignature(req, bodyText);
if (!isValid) {
return new NextResponse('Invalid signature', { status: 401 });
}
// 検証OKならJSONに戻す
const payload = JSON.parse(bodyText);
// ... 実際の処理へ ...
}
2. 冪等性(二重実行防止)の担保
GitHub Webhookは、送信に失敗した場合やタイムアウトした場合、同じイベントを再送してくる仕様があります(Retries)。また、誤操作などで重複したイベントが飛んでくることもあります。
DBへの書き込みやAIによる処理など、コストのかかる処理を二重に行わないよう、「受信済みID」をDBに記録してガード します。
これにはGitHubが付与するユニークID X-GitHub-Delivery を使用します。
DBスキーマ (Supabase / PostgreSQL)
CREATE TABLE webhook_events (
delivery_id TEXT PRIMARY KEY, -- GitHubからのID (Unique)
event_type TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
実装コード
import { createClient } from '@supabase/supabase-js';
// ... verifySignatureなどは省略
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
export async function POST(req: Request) {
// ... 署名検証後 ...
const deliveryId = req.headers.get('x-github-delivery');
const eventType = req.headers.get('x-github-event');
if (!deliveryId) {
return new NextResponse('Missing delivery ID', { status: 400 });
}
// 2. 冪等性チェック(DBに保存を試みる)
const { error } = await supabase
.from('webhook_events')
.insert({
delivery_id: deliveryId,
event_type: eventType,
status: 'pending'
});
// 主キー制約違反(duplicate key value)なら、既に受信済み
if (error && error.code === '23505') {
console.log(`Duplicate webhook event: ${deliveryId}`);
return new NextResponse('Already received', { status: 200 });
}
try {
// 3. メイン処理(非同期で実行する場合など)
await handleEvent(eventType, payload);
// 4. 完了ステータスに更新
await supabase
.from('webhook_events')
.update({ status: 'completed' })
.eq('delivery_id', deliveryId);
return new NextResponse('Processed', { status: 200 });
} catch (err) {
// エラー時はステータス更新
await supabase
.from('webhook_events')
.update({ status: 'failed' })
.eq('delivery_id', deliveryId);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
3. 解説:なぜこの構成か
署名検証に timingSafeEqual を使う理由
単純な文字列比較 (===) だと、文字列の不一致箇所が見つかった時点で処理が終了するため、応答時間から正解の署名の一部を推測される「タイミング攻撃」のリスクがあります。timingSafeEqual は常に一定時間で比較を行うため、このリスクを排除できます。
X-GitHub-Delivery を使う理由
ペイロードの中身(PR番号やコミットハッシュ)で重複判定をすることも可能ですが、X-GitHub-Delivery はGitHubが発行する「そのイベント配信自体」のユニークIDであるため、より確実かつ汎用的に重複を排除できます。
#紹介 この記事は以下のプロダクトを作成する過程で生まれました。ぜひ確認してみてください! https://diff-note.vercel.app/
おわりに
このパターンは、現在開発中のSaaS「Diff Note」でも実際に使用しています。 Vercel + Supabase の構成であれば、このように数行のコードと1つのテーブル追加だけで、堅牢なWebhook処理系を構築できます。
自動化ツールやBotを作る際は、ぜひ参考にしてみてください。