ブログ · · 約 10 分で
Webhook の署名検証が失敗する原因:生の本文・鍵の取り違え・時刻・プロキシ
Webhook の
要点
- 署名検証の
失敗の 多くは、 フレームワークが JSON に 変換した 後の 本文で 計算している ことが 原因です。 届いたままの バイト列で 計算し直すと 直ります。 - 次に
多いのは、 テスト用と 本番用の 鍵の 取り違え、 hex と base64 の 取り違え、 時刻の ずれ、 プロキシで URL が 変わる ことです。 - 各社で
署名する 中身 (本文だけ、 時刻+本文、 URL+パラメータ) と 符号化が 違うので、 表で 確かめてから 実装します。
Webhook の
失敗の原因(多い順)
- 本文を
変換した 。後の 値で 計算している Express の express.json()、Next.js の Pages Router の bodyParser、 FastAPI で 本文を Pydantic の モデルと して 受ける 書き方などでは、 署名の 計算に 使えるのは 変換後の 値だけに なります。 - 鍵の
取り違え 。テスト用と 本番用、 送信先ごとの 鍵、 CLI が 出した 一時的な 鍵を 取り違える ものです。 Stripe の 公式の 解説も 「最も よく ある エラーは、 間違った endpoint secret を 使用する こと」 と 書いています (Stripe の 署名エラーの )解説 。 - 符号化の
取り違え 。hex で 出すべき ところを base64 で 出している、 または その逆です。 鍵その ものが base64 で、 戻してから 使う 方式 (Standard Webhooks の whsec_…)も あります。 - 時刻の
ずれ 。署名に 時刻を 含める 方式では、 受け側の 時計が ずれていると 許容幅 ( 多くは 5 分) を 超えて 失敗します。 - URL が
変わる 。Twilio のように URL を 署名に 含める 方式では、 TLS を 終端する プロキシや ロードバランサーで httpsがhttpに変わる、 ポートが 付く、 と いった 違いで 失敗します。 - 鍵の
切り 。替えの 直後 新しい 鍵に 切り 替えたのに、 受け側が まだ 古い鍵だけで 検証している 場合です。
切り分けの手順
原因を
- 生の
本文と 。ヘッダーを 保存する 検証の 前に、 受け取った バイト列を そのまま ファイルか ログに 書き出します。 本文は base64 に して 保存すると、 改行や 文字コードが 途中で 変わりません。 - 使っている
鍵を 。確かめる 環境変数から 読んだ鍵の 先頭と 末尾の 数文字を 出力し、 送る 側の 管理画面の 値と 突き合わせます。 前後の 空白や 改行が 混ざっていないかも 見ます。 - 手元で
同じ 。計算を する 保存した 本文と 鍵で、 仕様どおりに HMAC を 計算します。 ここで 一致すれば、 受け側の コードが 本文を 変換しているのが 原因です。 - 一致しなければ、
署名する 。中身を 見直す 時刻や ID を 前に 付ける 方式か、 URL を 含める 方式か、 区切りの 文字は 何かを、 下の 表と 公式の 説明で 確かめます。 - 時計と
許容幅を 。確かめる 受け側の サーバーの 時刻が NTP で 合っているか、 許容幅を 0 に していないかを 見ます。
手元の
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 です
各社の署名の方式
2026-09-27 に
| 送る |
ヘッダー | 署名する |
方式と |
鍵 |
|---|---|---|---|---|
| Stripe | Stripe-Signaturet=…,v1=…) |
時刻.本文 |
HMAC-SHA256、 |
送信先ごとのwhsec_… |
| Shopify | X-Shopify-Hmac-Sha256 |
本文 | HMAC-SHA256、 |
アプリの |
| GitHub | X-Hub-Signature-256sha256=…) |
本文 | HMAC-SHA256、 |
Webhook に |
| Slack | X-Slack-Signaturev0=…) |
v0:時刻:本文 |
HMAC-SHA256、 |
アプリの |
| LINE | x-line-signature |
本文 | HMAC-SHA256、 |
チャネルシークレット |
| Twilio | X-Twilio-Signature |
URL+ |
HMAC-SHA1、 |
Auth Token |
| Standard Webhooks | webhook-signaturev1,…) |
ID.時刻.本文 |
HMAC-SHA256、 |
whsec_ の |
出典: Stripe、
表から
- Stripe と
Standard Webhooks は 。鍵の 見た 目が 同じ whsec_…でも使い方が 違います Stripe は 公式ライブラリに 文字列の まま 渡し、 Standard Webhooks は whsec_を外した 残りを base64 で 戻した バイト列を 鍵にします。 - Shopify の
鍵は 。アプリの client secret です Webhook ごとの 鍵ではありません。 - Twilio は
本文ではなく 。URL と パラメータに 署名します JSON で 届く ときは、 本文の SHA-256 を bodySHA256という クエリの パラメータで 付け、 URL の 一部と して 署名します。 Twilio は 「Twilio に 設定した URL を、 URL エンコードも 含めて そのまま 使う」 ことを 求めています。
変換前の本文の受け取り
原因のexpress.raw() でreq.body を
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 のapp.use(express.json()) をawait request.text()、request.bodyrequest.raw_post が
文字列に
定数時間の比較
計算した=== ではなくcrypto.timingSafeEqual、hmac.compare_digest、crypto.subtle.verify を
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 の0 を
鍵のwebhook-signature に
Webhook Admin から届く Webhook の検証
Webhook Admin はwebhookadmin のWebhook.verify に、
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 は、
署名付きの
よくある質問
手元では検証が通るのに、本番だけ失敗するのはなぜですか。
多いのは
JSON のキーの順番や空白が変わると署名は変わりますか。
変わります。
署名は hex と base64 のどちらで比べればよいですか。
送る