MeWrite Docs

Webhook signature verification failed

Webhookの署名検証に失敗した場合の汎用的な対処法

概要

外部サービスからのWebhookリクエストの署名検証に失敗した場合のエラーです。Stripe、GitHub、Slack、Discordなど多くのサービスで署名検証が必要です。

エラーメッセージ

Error: Webhook signature verification failed
Error: Invalid signature
Error: Request body digest did not match signature

共通の原因

1. raw bodyを使用していない

2. シークレットが間違っている

3. タイムスタンプの検証に失敗

4. ヘッダー名が間違っている

サービス別の実装

GitHub Webhooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import crypto from 'crypto'

function verifyGitHubSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const hmac = crypto.createHmac('sha256', secret)
  const digest = 'sha256=' + hmac.update(payload).digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  )
}

app.post('/webhook/github', express.raw({ type: '*/*' }), (req, res) => {
  const signature = req.headers['x-hub-signature-256'] as string
  const payload = req.body.toString()

  if (!verifyGitHubSignature(payload, signature, process.env.GITHUB_WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature')
  }

  const event = JSON.parse(payload)
  // 処理...
})

Slack Webhooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import crypto from 'crypto'

function verifySlackSignature(
  body: string,
  timestamp: string,
  signature: string,
  secret: string
): boolean {
  // リプレイ攻撃対策
  const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5
  if (parseInt(timestamp) < fiveMinutesAgo) {
    return false
  }

  const sigBasestring = `v0:${timestamp}:${body}`
  const mySignature = 'v0=' + crypto
    .createHmac('sha256', secret)
    .update(sigBasestring)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(mySignature),
    Buffer.from(signature)
  )
}

app.post('/webhook/slack', express.raw({ type: '*/*' }), (req, res) => {
  const timestamp = req.headers['x-slack-request-timestamp'] as string
  const signature = req.headers['x-slack-signature'] as string

  if (!verifySlackSignature(
    req.body.toString(),
    timestamp,
    signature,
    process.env.SLACK_SIGNING_SECRET!
  )) {
    return res.status(401).send('Invalid signature')
  }

  // 処理...
})

Discord Webhooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import nacl from 'tweetnacl'

function verifyDiscordSignature(
  body: string,
  signature: string,
  timestamp: string,
  publicKey: string
): boolean {
  const isValid = nacl.sign.detached.verify(
    Buffer.from(timestamp + body),
    Buffer.from(signature, 'hex'),
    Buffer.from(publicKey, 'hex')
  )
  return isValid
}

app.post('/webhook/discord', express.raw({ type: '*/*' }), (req, res) => {
  const signature = req.headers['x-signature-ed25519'] as string
  const timestamp = req.headers['x-signature-timestamp'] as string

  if (!verifyDiscordSignature(
    req.body.toString(),
    signature,
    timestamp,
    process.env.DISCORD_PUBLIC_KEY!
  )) {
    return res.status(401).send('Invalid signature')
  }

  // Interactionの確認応答
  const body = JSON.parse(req.body)
  if (body.type === 1) {
    return res.json({ type: 1 })
  }

  // 処理...
})

汎用的な署名検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// HMAC-SHA256ベースの汎用実装
function verifyHmacSignature(
  payload: string | Buffer,
  signature: string,
  secret: string,
  algorithm: string = 'sha256',
  encoding: 'hex' | 'base64' = 'hex'
): boolean {
  const hmac = crypto.createHmac(algorithm, secret)
  const expectedSignature = hmac.update(payload).digest(encoding)

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )
  } catch {
    return false
  }
}

Next.js App Routerでの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get('x-signature')

  if (!verifySignature(body, signature!, process.env.WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const payload = JSON.parse(body)
  // 処理...

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

タイムスタンプ検証

1
2
3
4
function isTimestampValid(timestamp: number, toleranceSeconds: number = 300): boolean {
  const now = Math.floor(Date.now() / 1000)
  return Math.abs(now - timestamp) <= toleranceSeconds
}

デバッグのヒント

1
2
3
4
5
// 署名をログで確認(開発時のみ)
console.log('Received signature:', signature)
console.log('Expected signature:', expectedSignature)
console.log('Body length:', body.length)
console.log('Body (first 100 chars):', body.substring(0, 100))

関連エラー

関連エラー

最終更新: 2025-12-22