# Webhook の署名検証が失敗する原因：生の本文・鍵の取り違え・時刻・プロキシ

> Webhook の署名検証が通らないときの原因を多い順に並べ、切り分けの手順と、Stripe・Shopify・GitHub・Slack・LINE・Twilio・Standard Webhooks の署名の対象と符号化の違いを表で整理します。

公開日: 2026-10-03
Source: https://webhookadmin.com/ja/blog/webhook-signature-verification-failed/

## 要点

- 署名検証の失敗の多くは、フレームワークが JSON に変換した後の本文で計算していることが原因です。届いたままのバイト列で計算し直すと直ります。
- 次に多いのは、テスト用と本番用の鍵の取り違え、hex と base64 の取り違え、時刻のずれ、プロキシで URL が変わることです。
- 各社で署名する中身（本文だけ、時刻＋本文、URL＋パラメータ）と符号化が違うので、表で確かめてから実装します。

Webhook の署名検証が失敗する原因で最も多いのは、届いたままのバイト列ではなく、変換した後の本文で署名を計算していることです。フレームワークが JSON に変換したオブジェクトを文字列に戻して計算すると、中身が同じでも署名は合いません。この記事では、原因を多い順に並べ、どこで食い違っているかを突き止める手順と、主要なサービスの署名の方式の違いをまとめます。

## 失敗の原因（多い順）

1. **本文を変換した後の値で計算している**。Express の `express.json()`、Next.js の Pages Router の bodyParser、FastAPI で本文を Pydantic のモデルとして受ける書き方などでは、署名の計算に使えるのは変換後の値だけになります。
2. **鍵の取り違え**。テスト用と本番用、送信先ごとの鍵、CLI が出した一時的な鍵を取り違えるものです。Stripe の公式の解説も「最もよくあるエラーは、間違った endpoint secret を使用すること」と書いています（[Stripe の署名エラーの解説](https://docs.stripe.com/webhooks/signature)）。
3. **符号化の取り違え**。hex で出すべきところを base64 で出している、またはその逆です。鍵そのものが base64 で、戻してから使う方式（Standard Webhooks の `whsec_…`）もあります。
4. **時刻のずれ**。署名に時刻を含める方式では、受け側の時計がずれていると許容幅（多くは 5 分）を超えて失敗します。
5. **URL が変わる**。Twilio のように URL を署名に含める方式では、TLS を終端するプロキシやロードバランサーで `https` が `http` に変わる、ポートが付く、といった違いで失敗します。
6. **鍵の切り替えの直後**。新しい鍵に切り替えたのに、受け側がまだ古い鍵だけで検証している場合です。

## 切り分けの手順

原因を推測で直すより、届いたものを保存して手元で同じ計算をするほうが早く片付きます。

1. **生の本文とヘッダーを保存する**。検証の前に、受け取ったバイト列をそのままファイルかログに書き出します。本文は base64 にして保存すると、改行や文字コードが途中で変わりません。
2. **使っている鍵を確かめる**。環境変数から読んだ鍵の先頭と末尾の数文字を出力し、送る側の管理画面の値と突き合わせます。前後の空白や改行が混ざっていないかも見ます。
3. **手元で同じ計算をする**。保存した本文と鍵で、仕様どおりに HMAC を計算します。ここで一致すれば、受け側のコードが本文を変換しているのが原因です。
4. **一致しなければ、署名する中身を見直す**。時刻や ID を前に付ける方式か、URL を含める方式か、区切りの文字は何かを、下の表と公式の説明で確かめます。
5. **時計と許容幅を確かめる**。受け側のサーバーの時刻が NTP で合っているか、許容幅を 0 にしていないかを見ます。

手元の計算は、例えば次のように書けます（GitHub の方式の例）。

```ts
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';

const raw = Buffer.from(readFileSync('body.b64', 'utf8'), 'base64'); // 保存した生の本文
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
const expected = 'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
console.log(expected); // 受け取った X-Hub-Signature-256 と比べる
```

GitHub は、実装を確かめるための値を公開しています。鍵が `It's a Secret to Everybody`、本文が `Hello, World!` のとき、署名は `sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17` です（[GitHub の検証の説明](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)）。自分の計算の関数にこの値を入れて一致すれば、関数そのものは正しいと分かります。

## 各社の署名の方式

2026-09-27 に各社の公式のドキュメントで確かめた内容です。

| 送る側 | ヘッダー | 署名する中身 | 方式と符号化 | 鍵 |
|---|---|---|---|---|
| Stripe | `Stripe-Signature`（`t=…,v1=…`） | `時刻.本文` | HMAC-SHA256、hex | 送信先ごとの `whsec_…` |
| Shopify | `X-Shopify-Hmac-Sha256` | 本文 | HMAC-SHA256、base64 | アプリの client secret |
| GitHub | `X-Hub-Signature-256`（`sha256=…`） | 本文 | HMAC-SHA256、hex | Webhook に設定した secret |
| Slack | `X-Slack-Signature`（`v0=…`） | `v0:時刻:本文` | HMAC-SHA256、hex | アプリの signing secret |
| LINE | `x-line-signature` | 本文 | HMAC-SHA256、base64 | チャネルシークレット |
| Twilio | `X-Twilio-Signature` | URL＋並べ替えた POST のパラメータ | HMAC-SHA1、base64 | Auth Token |
| Standard Webhooks | `webhook-signature`（`v1,…`） | `ID.時刻.本文` | HMAC-SHA256、base64 | `whsec_` の後ろを base64 で戻した値 |

出典: [Stripe](https://docs.stripe.com/webhooks)、[Shopify](https://shopify.dev/docs/apps/build/webhooks/subscribe/https)、[GitHub](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)、[Slack](https://docs.slack.dev/authentication/verifying-requests-from-slack/)、[LINE](https://developers.line.biz/ja/reference/messaging-api/)、[Twilio](https://www.twilio.com/docs/usage/webhooks/webhooks-security)、[Standard Webhooks の仕様](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)

表から分かる注意点が 3 つあります。

- **Stripe と Standard Webhooks は鍵の見た目が同じ `whsec_…` でも使い方が違います**。Stripe は公式ライブラリに文字列のまま渡し、Standard Webhooks は `whsec_` を外した残りを base64 で戻したバイト列を鍵にします。
- **Shopify の鍵はアプリの client secret です**。Webhook ごとの鍵ではありません。
- **Twilio は本文ではなく URL とパラメータに署名します**。JSON で届くときは、本文の SHA-256 を `bodySHA256` というクエリのパラメータで付け、URL の一部として署名します。Twilio は「Twilio に設定した URL を、URL エンコードも含めてそのまま使う」ことを求めています。

## 変換前の本文の受け取り

原因の 1 番目は、フレームワークの設定で直します。Express なら、Webhook の経路だけ `express.raw()` で受け、`req.body` を Buffer のまま検証に渡します。

```ts
import express from 'express';

const app = express();

// Webhook の経路は JSON に変換しない。express.json() より前に置く
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyGithub(req.body as Buffer, req.header('x-hub-signature-256') ?? '');
  if (!ok) return res.status(400).end();
  const event = JSON.parse((req.body as Buffer).toString('utf8'));
  // ここで処理（重い処理はキューへ）
  res.status(204).end();
});

app.use(express.json()); // ほかの経路用
```

Stripe の解説も、Express では `app.use(express.json())` を Webhook の経路より後に置くよう書いています。Next.js の App Router なら `await request.text()`、Django なら `request.body`（bytes）、Rails なら `request.raw_post` が生の本文です。フレームワークごとの書き方は[生の本文の取り方の記事](https://webhookadmin.com/ja/blog/webhook-raw-body-by-framework/)で扱います。

文字列にしてから計算する場合は、UTF-8 のまま扱います。GitHub の説明にも「本文は UTF-8 として扱う」とあります。Latin-1 などで読んでから戻すと、日本語や絵文字を含む本文で署名が変わります。

## 定数時間の比較

計算した署名と受け取った署名は、`===` ではなく定数時間の比較で比べます。普通の文字列の比較は最初に違う文字で止まるので、応答の時間から一致した長さを推測される余地があります。Node なら `crypto.timingSafeEqual`、Python なら `hmac.compare_digest`、Web Crypto なら `crypto.subtle.verify` を使います。

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyGithub(raw: Buffer, header: string): boolean {
  const expected = Buffer.from(
    'sha256=' + createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!).update(raw).digest('hex'),
  );
  const got = Buffer.from(header);
  // 長さが違うと timingSafeEqual は例外を投げるので先に比べる
  return got.length === expected.length && timingSafeEqual(got, expected);
}
```

## 時刻の許容幅と鍵の切り替え

Stripe の公式ライブラリは、署名の時刻と今の時刻の差を既定で 5 分まで許します。Slack も 5 分以上ずれた要求を捨てるよう求めています。許容幅は再生攻撃を防ぐためのものなので、失敗するからといって 0 や極端に大きな値にはせず、サーバーの時計を NTP で合わせます。Stripe は許容値に `0` を使わないよう明記しています。

鍵の切り替えの直後に失敗する場合は、切り替えの期間の扱いを確かめます。Stripe は古い鍵を最長 24 時間残せて、その間は鍵ごとに署名を 1 つずつ付けます。Standard Webhooks も `webhook-signature` に空白区切りで複数の署名を並べ、どれか 1 つが合えばよいとしています。受け側が最初の署名だけを見ていると、この期間に失敗します。受け側での切り替えの手順は[鍵の切り替えの記事](https://webhookadmin.com/ja/blog/webhook-secret-rotation/)で、許容幅の決め方は[再生攻撃の記事](https://webhookadmin.com/ja/blog/webhook-replay-attacks/)で扱います。

## Webhook Admin から届く Webhook の検証

Webhook Admin は Standard Webhooks の形式で署名します。ヘッダーと署名する文字列、ライブラリを使わない検証の手順は[Standard Webhooks の仕様と実装の記事](https://webhookadmin.com/ja/blog/standard-webhooks-explained/)にまとめました。Node では npm の `webhookadmin` の `Webhook.verify` に、生の本文とヘッダーを渡して検証します。

```ts
import express from 'express';
import { Webhook } from 'webhookadmin';

const wh = new Webhook(process.env.WEBHOOK_SECRET!); // 送信先の whsec_…
const app = express();

app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await wh.verify(req.body, req.headers); // { type, timestamp, data }
    res.sendStatus(204);
  } catch {
    res.sendStatus(400);
  }
});
```

`verify` は、本文に変換済みのオブジェクトを渡すと、生の本文を求める例外を投げます。時刻の許容幅は既定で前後 5 分、鍵の切り替え中（24 時間）は新旧どちらの鍵の署名でも通ります。署名の文字列の作り方は[署名検証のドキュメント](https://webhookadmin.com/ja/docs/sdk/#scheme)、各言語のライブラリは[ライブラリの一覧](https://webhookadmin.com/ja/docs/sdk/#libraries)にあります。

署名付きの送信・再送・配信記録を自分で作らずに用意したい場合は、Webhook Admin を無料で試せます（月 5 万通まで）。[https://app.webhookadmin.com/signup](https://app.webhookadmin.com/signup)

## よくある質問

### 手元では検証が通るのに、本番だけ失敗するのはなぜですか。

多いのは鍵の取り違え（本番の送信先の鍵ではなくテスト用の鍵や CLI が出した鍵を使っている）と、本番だけにあるプロキシやロードバランサーが本文や URL を書き換えている場合です。本番で受けた生の本文とヘッダーを保存し、手元で同じ計算をすると、どちらが原因か分かります。

### JSON のキーの順番や空白が変わると署名は変わりますか。

変わります。署名はバイト列に対して計算するので、意味が同じ JSON でも、空白・キーの順番・数値や Unicode の書き方が 1 文字でも違えば別の値になります。parse してから文字列に戻した本文では検証できません。

### 署名は hex と base64 のどちらで比べればよいですか。

送る側の仕様によります。Stripe・GitHub・Slack は hex、Shopify・LINE・Twilio・Standard Webhooks は base64 です。計算した HMAC を同じ符号化にしてから、定数時間の比較で比べます。
