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 を使用します。

typescript
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)

sql
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()
);

実装コード

typescript
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を作る際は、ぜひ参考にしてみてください。