Node.jsで実装する安全なWebhook:HMAC-SHA256認証とRetry処理の正しいやり方

Development tutorial - IT technology blog
Development tutorial - IT technology blog

背景:「無防備な」Webhookがなりすましリクエストにやられたとき

以前、ECサイトのプロジェクトで、Stripeからの決済通知を受け取るWebhookを実装したことがある。コードはシンプルで、POSTリクエストを受信 → 注文を更新 → 確認メールを送信、という流れだった。ところがある日、誰かがそのエンドポイントに大量の偽リクエストを送りつけてきた。わずか15分で、システムは300件以上の架空の「決済成功」を自動処理し、実在する顧客に300通の確認メールを送信してしまった。

問題はビジネスロジックにあったわけではない。問題はWebhookに認証の仕組みがなかったことだ。URLさえ知っていれば、誰でも偽データをPOSTできる状態だった。

振り返ると、問題は2点に集約される:

  • 偽造リクエスト:受信したリクエストが本物のStripeからのものか、ハッカーからのものかをどう見分けるか?
  • リトライ時のデータ重複:サーバーが30秒ダウンした場合、Stripeが再送し、同じ処理が2回実行されて二重請求が発生する。

HMAC-SHA256は1つ目の問題を解決する。冪等性(Idempotency)の仕組みとスマートなリトライ処理が2つ目を解決する。この記事はその2点に絞って解説する。

セットアップ

環境:Node.js 18+、Express。HMACライブラリの追加は不要で、Node.js組み込みのcryptoモジュールで対応できる。

mkdir webhook-secure && cd webhook-secure
npm init -y
npm install express dotenv

.envファイルを作成し、.gitignoreに追加することを忘れずに:

WEBHOOK_SECRET=your_super_secret_key_here
PORT=3000

WEBHOOK_SECRETは自分とWebhook送信サービスとの共有シークレットだ。Stripeの場合はダッシュボード → Webhooks → Signing secretから取得できる。パスワードと同様に厳重に管理すること。

実装の詳細

1. HMAC認証:リクエストの「電子署名」を検証する

仕組みはシンプルだ:Stripeがwebhookを送信する際、HMAC-SHA256(request_body, secret)を計算してヘッダーに付与する。サーバー側も受信したbodyで同じ計算を行い、2つの署名が一致すればリクエストは正当と判断する。

最もハマりやすいポイント:生のbody(Buffer)をパースしなければならない点だ。JSONとして先にパースしてしまうと、bodyの文字列がホワイトスペースの違いで変わり、署名が一致せず、正しいキーを使っていても401が返り続ける。

// webhook.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');

const app = express();

// HMACには生のbodyが必須 — ここではexpress.json()を使わない
app.use('/webhook', express.raw({ type: 'application/json' }));

function verifyHmacSignature(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const sigBuf = Buffer.from(signature, 'hex');
  const expBuf = Buffer.from(expected, 'hex');

  // timingSafeEqualでタイミング攻撃を防ぐ — ここで===は使わないこと
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];

  if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }

  const isValid = verifyHmacSignature(
    req.body,
    signature,
    process.env.WEBHOOK_SECRET
  );

  if (!isValid) {
    console.warn(`[WARN] Invalid signature from ${req.ip}`);
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body.toString());

  // すぐに200を返す — Stripeがタイムアウトしてリトライする前に
  res.status(200).json({ received: true });

  // レスポンス返却後に非同期で処理
  processEventIdempotent(event).catch(err =>
    console.error(`[ERROR] ${event.id}: ${err.message}`)
  );
});

なぜsignature === expectedではダメなのか?通常の文字列比較は最初の不一致文字で即座に処理を終える。攻撃者は1バイトずつ変えた署名を大量に送信してレスポンスタイムを計測し、正しい署名を推測できる——これがタイミング攻撃だ。timingSafeEqualは比較結果に関わらず常に一定時間で処理が完了するため、このような情報漏洩が発生しない。

2. 冪等性キー:何度受信しても一度だけ処理する

Webhookを送信するサービスは、数秒以内に200レスポンスを受信できない場合にリトライを行う。Stripeは3日間にわたって段階的にリトライ間隔を延ばす——5秒から始まり最終的には数時間おきになる。最悪の場合、同じイベントを10回以上受信することもある。制御できなければ、顧客への二重請求やメールの二重送信が発生する。

// 本番環境ではRedisを使うべき。ここではシンプルにSetで実装。
const processedEvents = new Set();

async function processEventIdempotent(event) {
  const eventId = event.id; // Stripe、GitHubなどはすべてユニークなイベントIDを持つ

  if (processedEvents.has(eventId)) {
    console.log(`[INFO] Duplicate event ${eventId}, skipping`);
    return;
  }

  // 処理前にマーク — 並行リクエストによる競合状態を防ぐ
  processedEvents.add(eventId);

  try {
    await processEvent(event);
    console.log(`[INFO] Event ${eventId} processed successfully`);
  } catch (err) {
    // エラー時はSetから削除してリトライを許可する
    processedEvents.delete(eventId);
    throw err;
  }
}

async function processEvent(event) {
  switch (event.type) {
    case 'payment.success':
      await handlePaymentSuccess(event.data);
      break;
    default:
      console.log(`[INFO] Unhandled event: ${event.type}`);
  }
}

3. Exponential Backoffによるリトライ:ダウンストリームのエラー時に再試行する

認証が完了したら、Webhookは通常、別のAPIを呼び出す——CRMへの注文更新、通知サービスの呼び出し、在庫システムの更新など。それらのどれかが一時的にダウンしている場合、スマートなリトライがなければデータが失われる。

リトライ実装には3つの典型的な罠がある:サービスが完全に落ちているのに無限リトライする、ジッターがなく大量のクライアントが同時にリトライして輻輳を起こす(Thundering Herd問題)、そして何度試しても失敗する400系エラーまでリトライしてしまうこと。以下のコードはその3つすべてに対処している。

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;

      // クライアントエラー(4xx)はリトライしない — 何度試しても失敗する
      if (err.statusCode >= 400 && err.statusCode < 500) throw err;

      if (attempt === maxRetries) break;

      // Exponential backoff + jitter: 複数クライアントの同時リトライを防ぐ
      const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 500;
      console.log(`[WARN] Attempt ${attempt} failed. Retry in ${Math.round(delay)}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw new Error(`All ${maxRetries} attempts failed: ${lastError.message}`);
}

// handlePaymentSuccess内での使用例:
async function handlePaymentSuccess(data) {
  await retryWithBackoff(async () => {
    const res = await fetch('https://crm.example.com/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId: data.orderId })
    });

    if (!res.ok) {
      const err = new Error(`CRM error: ${res.status}`);
      err.statusCode = res.status;
      throw err;
    }
  });
}

テストとモニタリング

curlでテストする

まずサーバーを起動する:

node webhook.js
# Webhook server running on port 3000

HMACを計算して正当なリクエストを送信する:

PAYLOAD='{"type":"payment.success","id":"evt_123","data":{"orderId":"ORD-456"}}'
SECRET="your_super_secret_key_here"

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: $SIGNATURE" \
  -d "$PAYLOAD"
# {"received":true}

署名が不正な場合のテスト——401が返ることを確認:

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: fake_signature_here" \
  -d '{"type":"test"}'
# {"error":"Invalid signature"}

冪等性のテスト——同じイベントIDを2回送信し、2回目がスキップされることを確認:

# 1回目の送信
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: $SIGNATURE" \
  -d "$PAYLOAD"

# 2回目の送信 — サーバーのログに「Duplicate event evt_123, skipping」が表示されるはず
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: $SIGNATURE" \
  -d "$PAYLOAD"

本番環境で監視すべきメトリクス

デバッグを楽にするためにログミドルウェアを追加する:

app.use('/webhook', (req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(JSON.stringify({
      ts: new Date().toISOString(),
      status: res.statusCode,
      duration_ms: Date.now() - start,
      ip: req.ip
    }));
  });
  next();
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook server running on port ${PORT}`));

毎日確認すべき4つの数値:

  • 401レートの急増:エンドポイントへのプロービング攻撃が行われている可能性がある
  • レスポンスタイムが頻繁に3秒超え:処理をキューに移行する必要がある
  • 重複イベント数の増加:上流サービスが多くリトライしている——自サーバーに何らかの問題がある
  • リトライ上限到達:ダウンストリームサービスが不安定——即時アラートが必要

このセットアップはほとんどのユースケースで本番稼働に十分耐えられる。イベント数が毎秒数千件規模になったら、インライン処理の代わりにキュー(Bull/BullMQ + Redis)にイベントを投入する方式に切り替えよう。HMACと冪等性のロジック自体は変更不要で、processEvent()queue.add(event)に置き換えるだけでいい。

Share: