← 記事一覧に戻る

Stripe Webhook 署名検証が失敗する・`No signatures found` エラーが解決しない時の原因と対処法【Next.js / Express / Node.js】

はじめに

Stripe の Webhook を実装していると、以下のようなエラーに突き当たることがあります。

StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?

または

Error: Webhook signature verification failed.

エラーメッセージには「raw request body を渡しているか?」と書かれていますが、「渡してるつもりなのになぜ…」となりがちです。この記事ではよくある原因を網羅的に解説します。


Stripe Webhook 署名検証の仕組み

まず仕組みを理解することが重要です。Stripe は Webhook リクエストを送信する際、リクエストボディと Webhook シークレットを組み合わせて HMAC-SHA256 署名を生成し、Stripe-Signature ヘッダーに付与します。

受信側では stripe.webhooks.constructEvent() を呼び出すことで、同じ計算を行い署名が一致するか確認します。

重要: この検証は バイト単位 で行われます。ボディを一度でも文字列変換・JSON パース・再シリアライズすると署名が合わなくなります。


原因1(最多): raw body ではなくパース済みのボディを渡している

Express で最もよく起きる原因です。express.json() などのミドルウェアが先にボディを解析してしまい、req.body がオブジェクトになってしまっています。

よくある間違いコード

// app.js(間違い)
app.use(express.json()) // ← ここで全ルートのボディがパースされる

app.post('/webhook', (req, res) => {
  const sig = req.headers['stripe-signature']
  // req.body はすでに JSON.parse 済みのオブジェクト → 署名検証が必ず失敗する
  const event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET)
})

正しい実装

// app.js(正しい)
app.use(express.json()) // 他のルート用

// Webhook ルートだけ raw body を取得する
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }), // ← raw buffer として受け取る
  (req, res) => {
    const sig = req.headers['stripe-signature']
    let event

    try {
      // req.body が Buffer になっている
      event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET)
    } catch (err) {
      console.error('Webhook verification failed:', err.message)
      return res.status(400).send(`Webhook Error: ${err.message}`)
    }

    // イベント処理
    switch (event.type) {
      case 'payment_intent.succeeded':
        // 処理
        break
    }

    res.json({ received: true })
  }
)

ポイント: express.raw({ type: 'application/json' }) をルートレベルのミドルウェアとして Webhook ルートに適用することで、そのルートだけ raw buffer を受け取れます。


原因2: Next.js Pages Router で bodyParser を無効化し忘れている

Next.js の API Routes はデフォルトで body を自動パースします。Stripe Webhook ルートではこれを無効にする必要があります。

間違い(デフォルト設定のまま)

// pages/api/webhook.ts
import Stripe from 'stripe'
import type { NextApiRequest, NextApiResponse } from 'next'

const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const sig = req.headers['stripe-signature'] as string
  // req.body はすでにパース済み → 失敗する
  const event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
}

正しい実装

// pages/api/webhook.ts
import { buffer } from 'micro'
import Stripe from 'stripe'
import type { NextApiRequest, NextApiResponse } from 'next'

const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)

// ← これが必須:Next.js の bodyParser を無効化する
export const config = {
  api: {
    bodyParser: false,
  },
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const sig = req.headers['stripe-signature'] as string
  const buf = await buffer(req) // raw buffer を取得

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(buf, sig, ({}).STRIPE_WEBHOOK_SECRET!)
  } catch (err: any) {
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  // イベント処理...
  res.json({ received: true })
}

micro パッケージは Next.js に同梱されているため、追加インストール不要です。


原因3: Next.js App Router での落とし穴

App Router(Next.js 13+)では、req.json() を呼んではいけません。

間違い

// app/api/webhook/route.ts(間違い)
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const body = await req.json() // ← NG: パース済みオブジェクトになる
  const sig = req.headers.get('stripe-signature')!

  // body はオブジェクト → 署名検証が失敗する
  const event = stripe.webhooks.constructEvent(body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
}

正しい実装

// app/api/webhook/route.ts(正しい)
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const body = await req.text() // ← OK: 生のテキストとして取得
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
  } catch (err: any) {
    console.error('Webhook error:', err.message)
    return NextResponse.json({ error: err.message }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session
      // 処理
      break
  }

  return NextResponse.json({ received: true })
}

App Router では req.text() または req.arrayBuffer() で raw body を取得します。


原因4: Webhook シークレットの取り違え

Stripe のシークレットには 2種類 あり、混同しやすいです。

種類 確認場所 形式
Stripe CLI のシークレット stripe listen 実行時に表示される whsec_ から始まる
Dashboard のシークレット Stripe Dashboard > Webhooks > エンドポイント詳細 whsec_ から始まる

ローカル開発時stripe listen --forward-to localhost:3000/api/webhook を実行し、そこで表示される whsec_... を環境変数に設定します。Dashboard のシークレットとは 別物 です。

# ローカル開発時
stripe listen --forward-to localhost:3000/api/webhook
# => Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxxxxx (^C to quit)
#                                                ↑ これをローカルの .env.local に設定する

# .env.local
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx  # ← stripe listen で表示されたもの

# .env.production(本番)
STRIPE_WEBHOOK_SECRET=whsec_yyyyyyyyyyyyyy   # ← Dashboard のエンドポイントのシークレット

よくある混在パターン

  • 本番環境に CLI 用のシークレットを設定してしまっている
  • テストモード用シークレットを本番モードで使っている
  • 複数のエンドポイントを作って、別エンドポイントのシークレットを使っている

原因5: タイムスタンプ許容範囲のエラー

以下のエラーが出る場合、リクエストのタイムスタンプが古すぎます(デフォルトで 300秒 = 5分以内)。

StripeSignatureVerificationError: Timestamp outside the tolerance zone

これはリプレイ攻撃を防ぐための仕組みです。ローカル開発では発生しにくいですが、Stripe CLI でイベントを再送信した場合や、処理が重くて時間がかかっている場合に起きることがあります。

// テスト時のみ許容時間を延ばす(本番では使わない)
const event = stripe.webhooks.constructEvent(
  body,
  sig,
  ({}).STRIPE_WEBHOOK_SECRET!,
  // 第4引数: タイムスタンプの許容秒数(デフォルト300秒)
  // テスト時に大きな値を設定する場合
  "production" === 'test' ? undefined : 300
)

本番では使いませんが、テスト環境で constructEvent の第4引数に undefined を渡すとタイムスタンプチェックを無効にできます(Stripe SDK v10+)。


デバッグ手順まとめ

署名検証エラーが出たら、以下の順番で確認しましょう。

// デバッグ用のログを追加する
export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')

  console.log('=== Stripe Webhook Debug ===')
  console.log('body type:', typeof body)         // "string" であること
  console.log('body length:', body.length)        // 0 でないこと
  console.log('sig:', sig?.substring(0, 30))      // null でないこと
  console.log('secret prefix:', ({}).STRIPE_WEBHOOK_SECRET?.substring(0, 10)) // whsec_xxx...

  // ...
}

チェックポイント:

  1. body が文字列またはバッファであること(オブジェクトでないこと)
  2. sig ヘッダーが null でないこと
  3. Webhook シークレットが正しい環境のものか(ローカル vs 本番、テスト vs 本番)
  4. タイムスタンプが 5分以内か(stripe listen で再送した場合は期限切れになりやすい)

まとめ

エラーの原因 解決策
Express で req.body がパース済み express.raw({ type: 'application/json' }) をルートに適用
Next.js Pages Router で自動パース export const config = { api: { bodyParser: false } }
Next.js App Router で req.json() 使用 req.text() または req.arrayBuffer() に変更
Webhook シークレットの取り違え CLI 用と Dashboard 用を混同しないよう環境変数を整理
タイムスタンプが古い Stripe CLI で再送した場合は再度 stripe trigger を実行

Stripe Webhook の署名検証エラーは「raw body を渡しているつもりなのに渡せていない」というケースがほぼ全てです。フレームワークのボディパーサーの挙動を把握し、Webhook ルートだけ別処理することで確実に解決できます。