Webhook の署名検証が失敗した?原因を絞り込む
webhook signature verification failed というエラーが意味するのは一つだけだ。自分のコードが計算したダイジェストが、リクエストヘッダーに入っていたダイジェストと一致しない。権限や有効期限の問題ではないし、プロバイダーの SDK のバグであることもまずない。プロバイダーがハッシュしたバイト列と、こちらがハッシュしたバイト列が、どこかで食い違っている。
結果を決める入力は 4 つある。どのバイト列に署名したか、どの鍵バイトを使ったか、どのハッシュアルゴリズムが走ったか、どのテキストエンコードで比較したか。どれか一つを間違えれば、失敗の見え方はまったく同じになる。エラー自体はどれが原因かを教えてくれない。メッセージを読み込むより、入力を一つずつ潰していく方が速い。
出発点となる分岐を選ぶ。
署名が一致しない? 分岐は 3 つ:
├─ フレームワークが先に JSON をパースしていないか? → セクション 3
├─ ヘッダー値に接頭辞が付いているか、base64 に見えるか? → セクション 4
└─ プロバイダーのヘッダーにタイムスタンプが入っているか? → セクション 2
1. 署名の不一致が実際に教えてくれること
検証とは 2 つのバイト列の比較にすぎない。失敗するときは、次の 4 つのうちちょうど一つが間違っている。しかも 4 つは互いに独立している。
一つ目は、どのバイト列に署名したか。プロバイダーは特定のバイト列をハッシュした。それはリクエストボディだけかもしれないし、ボディの先頭にタイムスタンプを貼り付けたものかもしれない。フレームワークが JSON をパースしてオブジェクトを渡してきた時点で、そのバイト列は手元から消えていて、確実には復元できない。これがセクション 3 の話で、原因としては圧倒的に多い。
二つ目は、どの鍵バイトを使ったか。同じシークレット文字列でも、UTF-8 のテキストとして読むか、hex として読むか、base64 として読むかで、鍵は別物になる。設定ローダーが取り込んだ余分な改行が一つ混ざっているだけでも同じことが起きる。この軸にはもう一つの失敗が潜んでいる。正しい鍵の読み方を間違えているのではなく、シークレットそのものが別物だという場合で、それはセクション 6 の話になる。
三つ目は、どのエンコードで比較したか。SHA-256 のダイジェストは生の 32 バイトだ。hex と base64 は、その同じバイト列をテキストとして書き下す 2 通りの方法であり、見た目が似ることはない。片方をもう片方と比べれば、中身のバイト列が一致していても hmac signature mismatch が永遠に出続ける。
四つ目は、どのハッシュアルゴリズムが走ったか。ほとんどのプロバイダーは SHA-256 を使い、それをドキュメントに明記しているので、この軸で手間を取られることはふつうない。例外は GitHub だ。配信ごとに X-Hub-Signature(HMAC-SHA1)が X-Hub-Signature-256(HMAC-SHA256)と並んで送られてきて、GitHub 自身のドキュメントは SHA-1 のヘッダーを「後方互換のためだけに含めている」と述べ、256 の方を推奨している。読み間違えれば、バイト列を比べる前に長さで分かる。セクション 2 のボディを同じシークレットで SHA-1 署名すると sha1=ba2954d180839d8170b08b32cd38483775aaae96 になり、hex で 40 文字。SHA-256 ダイジェストの 64 文字とは桁数が違う。
デバッグ中はこの 4 つを混ぜないこと。ある軸を切り離すには、自分で内容を決められる入力を使って、アプリケーションの外でダイジェストを計算してみるのが早い。ボディとシークレットを HMAC ジェネレーターに貼り付けて、何が出てくるかを見る。処理はすべてブラウザ内で完結し、シークレットがページの外に出ることはないので、本番の署名シークレットを貼り付けても問題ない。HMAC が回している SHA-256 のプリミティブは素の SHA-256 ハッシュと同じで、そこにシークレットを鍵として与えているだけだ。プロバイダーの値を手計算で再現できたなら暗号側は問題なく、バグはリクエストの扱いの側にある。
2. 大手 4 社が実際に署名している対象
実装を壊す思い込みで多いのは、「どのプロバイダーもリクエストボディだけに署名している」というものだ。大手 4 社のうち 2 社はそうしていない。各社が実際にハッシュしている対象を、現行のプロバイダードキュメントに照らして確認したものが次の表だ。
| プロバイダー | ヘッダー | 署名対象の文字列 | エンコード | 値の接頭辞 | シークレット | タイムスタンプ許容幅 |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | エンドポイント署名シークレット(whsec_ 接頭辞) | 5 分(300 秒) |
| GitHub | X-Hub-Signature-256 | rawBody(接頭辞なし) | hex | sha256= | Webhook シークレットトークン | なし(タイムスタンプを送らない) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | 署名シークレット | 5 分 |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | なし | アプリのクライアントシークレット(Webhook 専用のシークレットではない) | なし |
この 4 社は、たまたま直交する 3 つの軸を網羅している。署名対象の文字列はボディ単体か、タイムスタンプを連結したものかのどちらかで、区切り文字まで違う。Stripe は .、Slack は : を使う。エンコードは 3 社が hex、1 社が base64。シークレットは 3 社が Webhook 専用の資格情報から取るが、Shopify はアプリのクライアントシークレットを使う。ここを間違えるケースがいちばん多いのは、管理画面に「webhook」と書かれたフィールドがあるのに、それが欲しいものではないからだ。仕組み自体はこの 4 社に限らない。日本で使うなら LINE Messaging API も webhook を HMAC で署名して送ってくるので、このセクションの考え方はそのまま通じる。署名対象の正確な形式は LINE 自身のドキュメントで確認すること。
同じボディを同じシークレットで 4 通りに署名すると、こうなる。
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| 形式 | 値 |
|---|---|
| GitHub 形式 | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| Shopify 形式 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| Stripe 形式 | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| Slack 形式 | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
最初の 2 行はセットで読んでほしい。同じ 32 バイトのダイジェストを 2 通りに書き下したものだからだ。hex なら 64 文字、base64 ならパディングを含めて 44 文字になる。2 つの文字列を見比べて等しいと思える要素は何もない。だからエンコードをまたいで比較すると、「でもシークレットは合っている」という確認を何度やっても消えない不一致になる。
残りの 2 行はもう半分を裏づけている。ボディも、シークレットも、アルゴリズムも同じなのに、どちらのダイジェストも GitHub のものとは似ていない。ハッシュ対象の文字列がタイムスタンプから始まるようになったからだ。stripe webhook signature verification failed という報告のほとんどは、この行に行き着く。コードがボディ単体をハッシュしていて、t の値とドットを前に付けていない。HMAC ジェネレーターでメッセージ欄だけを書き換え、出力フォーマットを切り替えれば、4 つすべてを手元で再現できる。
タイムスタンプ列には実務上の帰結が一つある。Stripe や Slack のダイジェストは数分しか有効ではないので、今日キャプチャした署名を明日のテストで再生できない。GitHub と Shopify の署名は永久に安定しているのでデバッグは楽だが、そのぶんリプレイ対策は自分で用意することになる。
3. 生のリクエストボディという問題
webhook signature verification failed の報告は、大半がこのセクションに行き着く。
フレームワークがすでにバイト列を壊している
Web フレームワークはパースの手間を省くために作られている。その便利さが署名検証を壊す。ハンドラーが動き始めた時点で、元のバイト列はもう残っていないからだ。
express.json() はリクエストストリームを読み、パースし、req.body を JavaScript のオブジェクトに置き換える。ストリームは消費済みで、二度は読めない。FastAPI では Pydantic モデルや dict のボディパラメータを宣言した時点で、関数に入る前にフレームワークが読み込んでパースする。Rails はコントローラーのアクションより前に走るミドルウェアを通して、JSON ボディから params を組み立てる。Spring の Jackson コンバーターはボディを DTO クラスに変換し、その下にある HttpServletRequest の入力ストリームはデフォルトで一度しか読めない。
ここにバグは一つもない。どれも設定されたとおりに動いている。ただ、署名が対象にしているのはバイト列で、オブジェクトから作り直したバイト列はプロバイダーがハッシュしたものと別物になる。
再シリアライズが通ってしまうことがあり、それが罠になる
よく言われるのは「再シリアライズするとバイト列が変わる」だ。これは半分しか説明していない。抜けている半分こそが、この障害の診断を難しくしている。まったく変わらない場合があるのだ。
ペイロードの形ごとに JSON.stringify(JSON.parse(body)) === body を実測したものが次の表だ。
| ペイロードの形 | round-trip 後のバイト列 | 変化 |
|---|---|---|
{"id":42,"event":"user.created"} | 同一 | なし。だからローカルテストは通ってしまう |
{"amount":1.0} | 変化する | → {"amount":1} |
{"n":1e3} | 変化する | → {"n":1000} |
{"id":12345678901234567890} | 変化する | → {"id":12345678901234567000}(精度が落ちる) |
{"name":"caf\u00e9"} | 変化する | → {"name":"café"}(6 バイトが 2 バイトになる) |
{"a":1}\n | 変化する | 末尾の改行が飲み込まれる |
{ "a" : 1 } | 変化する | 内部の空白が飲み込まれる |
{"v":-0.0} | 変化する | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | 変化する | → {"p":0.1} |
効いているのは最初の行だ。整数と短い ASCII 文字列だけのフラットなオブジェクトはバイト単位で round-trip するので、パースして再文字列化する検証コードは、その手のフィクスチャに対して書いたテストをすべて通してしまう。そしてデプロイすると、1.0 という金額、2^53 を超える ID、アクセント付きの顧客名を運んできた最初のペイロードで落ちる。落ちるのはそのイベントだけで、ほかは通り続ける。
これが「ローカルでは動くのに本番で 401 が散発する」の中身であり、常に失敗する検証コードよりずっと厄介だ。常に落ちるものは 1 時間で直る。一部のイベントだけ落ちるものは、プロバイダーの不調に見えるので、リトライで流したまま数週間残る。失敗率が 0% と 100% のあいだのどこかにあるなら、まずこの表を見るべきだ。
キーの順序は誰もが疑う原因だが、実際にはいちばんあり得ない。文字列キーについては JSON.parse が挿入順を保つからだ。真犯人は数値と空白である。
各フレームワークで生のボディを取る
Express では、グローバルな JSON パーサーより前にルート単位のパーサーを登録する。
const express = require('express');
const crypto = require('crypto');
const app = express();
// このルートは app.use(express.json()) より前に登録しなければならない。
// body-parser がリクエストをパース済みと記録するため、後から raw() を書いても黙って {} になる。
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // オブジェクトではなく Buffer
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // Buffer をそのままハッシュする。toString() は挟まない
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // 他のルートは今までどおりパース済み JSON を受け取る
app.listen(3000);
ミドルウェアの順序を変えられない場合は、パースの途中でコピーを取っておく。
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI では、Starlette がボディをキャッシュするので、パース済みモデルも受け取るハンドラーの中でも await request.body() は元のバイト列を返す。
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # 受信したままのバイト列
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails では request.raw_post がパース前のボディを文字列で返す。
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go では自分でボディを読み、読み終わったあとは中身が空になることを忘れないようにする。
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Unmarshal は raw から行う。r.Body からは絶対に行わない。もうバイトは残っていない。
w.WriteHeader(http.StatusOK)
}
Spring では byte[] を要求すると Jackson を完全に飛ばせる。
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
フィルターで検証するしかなく、コントローラーのシグネチャを変えられないときの代替が ContentCachingRequestWrapper だ。ただしこれ自体に罠がある。getContentAsByteArray() がバイト列を返すのは下流の誰かがストリームを読んだあとだけなので、chain.doFilter(...) より前に呼ぶと空の配列が返ってくる。
4. エンコードの不一致:ダイジェストと鍵
自分のダイジェストとヘッダー値のあいだには、独立した 3 つのエンコード上の判断が挟まっている。どれ一つでも比較を壊せる。
一つ目はダイジェストのエンコードだ。HMAC-SHA256 の出力は 32 バイトで、小文字の hex で書けば 64 文字、標準の base64 で書けば = のパディングを含めて 44 文字になる。セクション 2 の 2 行がその実例だ。
| エンコード | 文字数 | 同じ 32 バイトの書き下し |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
見慣れないヘッダーは、長さと文字種だけで見分けられる。値が 0-9a-f の 64 文字なら hex。= で終わる 44 文字か、+、/、大文字を含むなら base64。推測ではなく確かめたいときは、base64 の値を Base64 デコーダーに通して 32 バイトになるかを見る。32 バイトになるなら、2 つの文字列は同じダイジェストを指していて、比べていたのは署名ではなくテキストの表記形式だったということだ。
二つ目は値の接頭辞だ。GitHub は hex の前に sha256= を付けて送る。Slack は v0=。Stripe はすべてを key=value のカンマ区切りリストで包む。これらの文字はダイジェストの一部ではないので、ヘッダーから接頭辞を外すか、自分の値に接頭辞を足すかのどちらかをする。どちらもやらないまま比較するのが、正しく書けているコードが hmac signature mismatch を報告する最大の原因だ。しかも Node では不一致としてすら報告されない。理由はセクション 7 で説明する。
三つ目は鍵のエンコードだ。シークレットもバイト列であり、同じ文字列を UTF-8、hex、base64 のどれで読むかで鍵は 3 通りになる。whsec_... のようなテキストトークンを渡してくるプロバイダーは UTF-8 を期待しているが、社内システムでは署名前にデコードが必要な base64 や hex のシークレットが配られていることも珍しくない。この失敗の形は JWT 版とまったく同じで、あるシークレットが base64 なのか平文なのかを見分ける方法まで含めて JWTの「invalid signature」エラー:原因の特定と修正方法で詳しく扱っている。
5. タイムスタンプと許容幅
完全に一致するダイジェストを計算できていても、拒否されることはある。タイムスタンプを付けてくるプロバイダーは、それを検査することを前提にしている。古いタイムスタンプは、署名としては正しいのに拒否しなければならないものだ。
| プロバイダー | タイムスタンプの場所 | 窓 |
|---|---|---|
| Stripe | Stripe-Signature 内の t= | 5 分(300 秒) |
| Slack | X-Slack-Request-Timestamp ヘッダー | 5 分 |
| GitHub | 送られない | 該当なし |
| Shopify | 送られない | 該当なし |
窓の設定はどちらに振れても痛い。広すぎれば、キャプチャされたリクエストは許した時間だけ再生可能なまま残り、タイムスタンプを検査する意味はほとんど失われる。狭すぎれば、ふつうのクロックドリフトが本物の配信を弾き始める。両社が選んだのは 5 分で、これをそのまま使うのは妥当なデフォルトだ。
許容幅を広げる前に、時計を確認する。コンテナイメージは NTP を動かしていないし、スナップショットから復帰した VM は実時間から数分ずれていることがあり、そのことはログのどこにも出ない。じわじわずれていくホストは、最初は散発的で最後には全滅する失敗を生む。コードのリグレッションに見えるが、原因は時計だ。
時計まわりのもう一つのバグは単位の不一致だ。表にあるプロバイダーはすべてエポック秒を送る。これを JavaScript の Date.now() のようなミリ秒の値と比べれば、差は実際の経過時間のおよそ 1000 倍になるので、どのイベントもあらゆる妥当な窓の外に出る。症状は、ダイジェスト自体は一致しているのに許容幅の検査が配信を 100% 拒否する、という形になる。いま手元にあるのがどちらの単位か分からないときは桁数が手がかりになる。Unixタイムスタンプ完全ガイド:秒・ミリ秒・マイクロ秒の変換とタイムゾーン・DST対策に、変換方法とその周辺のタイムゾーンの罠をまとめてある。
署名対象の文字列を組み立てるときは、パースして書式を整え直した数値ではなく、ヘッダーから来た生のタイムスタンプ文字列を使う。1700000000 を浮動小数点数にパースして出力し直すと 1700000000.0 になりうるが、これは別のバイト列だ。
6. シークレットそのものが違う場合と、ローテーション
エンコードの話に踏み込む前に、いちばん素朴な原因を潰しておく。そのシークレットが、そもそも正しいシークレットではない可能性だ。Stripe のドキュメントは「Stripe はエンドポイントごとに一意のシークレットキーを生成する」と明記していて、同じ URL をテストキーと本番キーの両方に向けた場合は「シークレットはそれぞれ別になる」とも書いている。ここから同じ間違いの 3 つの形が出てくる。
テストモードと本番モードは別々のシークレットを持つので、ダッシュボードがテストモードのときにコピーした値では本番の配信がすべて落ちる。エンドポイントごとにも別で、ドキュメントは「複数のエンドポイントを使う場合、署名を検証したいエンドポイントごとにシークレットを取得しなければならない」と付け加えている。2 つのエンドポイントを 1 つのハンドラーに向けて、環境変数のシークレットが 1 つだけなら、トラフィックの半分が落ちる。さらに stripe listen は CLI のローカル転送用に署名シークレットを表示するが、これはダッシュボードに登録したどのエンドポイントとも別のエンドポイントなので、両者は交換できない。
どれも外から見るとエンコードのバグには見えない。ダイジェストの形は整っていて、比較処理も正しく、環境変数に入っている値も本物の Stripe のシークレットだ。ただ、この配信に署名したシークレットではない。
ローテーションは、同じ軸が足元で動くことだ。エンコードの問題にはいちばん見えず、コードのバグと誤診されることがいちばん多い。コードは何も変えていないのに、昨日まで通っていた検証が今日は一部のイベントで落ちる。
重複期間は意図的なものだ。Stripe はローテーション後も最大 24 時間、古いエンドポイントシークレットを有効なままにする。その間、Stripe-Signature ヘッダーは有効なシークレットごとに 1 つずつ v1 署名を運ぶ。Shopify は逆方向で、ローテーション後にダイジェストの計算へ新しいシークレットを使い始めるまで最大 1 時間かかることがあるので、その間に必要なのは古い方だ。
コードを壊すのは Stripe の挙動の方だ。ヘッダーは署名が 1 つだけ入っているように見える。, で分割して最初に見つかった v1 を取る書き方は、2 つになる瞬間まではうまく動く。そこから先は、どのシークレットがどのイベントに署名したかによって、おおよそ半分しか一致しなくなる。すべてを走査すること。
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // 元の文字列のまま保持する
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
ループ以外にも、ここには重要な点が 2 つある。タイムスタンプは届いたときの文字列のまま署名対象のペイロードに入れている。そしてボディはテンプレート補間ではなくバイト列として連結している。補間を使えば、先に UTF-8 としてデコードされてしまうからだ。
自分の側でローテーションするときも同じ形になる。重複期間のあいだは古いシークレットと新しいシークレットの両方を受け入れ、その後で古い方を捨てる。移行先のシークレットには十分なエントロピーが必要なので、手で打ち込むのではなく生成する。256 ビットのランダム値なら 署名シークレット生成ツールのようなものを使えばいい。
7. タイミングを漏らさずに署名を比較する
ダイジェストが 2 つそろったあと、それをどう比較するかはセキュリティ上の判断になる。文字列の等価比較は違うバイトを見つけた時点で返るので、かかった時間から先頭何バイトが正しかったかが漏れる。大量のリクエストを送れる攻撃者は、それを使って有効な署名を 1 バイトずつ復元していく。インターネット越しでは遅くノイズも多いが、ローカルネットワークなら十分に実用的だ。
どのランタイムにも定数時間比較の関数がある。
| 言語 | 定数時間比較 | 長さが違うとき |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | 例外を投げる |
| Python | hmac.compare_digest(a, b) | False を返す |
| Go | hmac.Equal(a, b) | false を返す |
| PHP | hash_equals($known, $user) | false を返す |
| Ruby | OpenSSL.secure_compare(a, b) | false を返す |
厄介な障害はこの最後の列から生まれる。外れ値は Node で、しかも黙って false を返してはくれない。
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
発火する条件は単純だ。hex の SHA-256 ダイジェストは 64 文字、X-Hub-Signature-256 の値は sha256= の 7 文字を含めて 71 文字。接頭辞を外し忘れれば 2 つのバッファは長さが違うので、timingSafeEqual は false を返さずに例外を投げる。捕まえなければ、その例外はハンドラーの外に伝播し、Express は 500 に変える。
webhook 401 unauthorized のレスポンスを探していたのに、返ってくるのはサーバーエラーだ。だからハンドラーを読み、その先のデータベース呼び出しまで読む。本当のバグは比較の 1 行上にある。64 文字の hex ダイジェストを 44 文字の base64 と比べたときも同じ理由で例外になる。つまり Node ではエンコードの不一致も、きれいな拒否ではなく 500 として表に出てくる。
直し方は、自分で長さを確認して false を返すことだ。
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // 呼び出す前にガードする
return crypto.timingSafeEqual(a, b);
}
長さが漏れることは害にならない。ダイジェストの長さはアルゴリズムで決まっていて公開情報だ。漏らしてはいけないのは、どこまでの接頭辞が一致したかである。HMAC ジェネレーターの検証タブは早期に返らず、長さの差を定数時間比較と同じアキュムレーターに折り込んでいる。だから長さが違っても例外ではなく素の false が返り、使い捨てのコードを書かずにヘッダー値と計算したダイジェストを突き合わせられる。
8. トランスポート層がバイト列を書き換えていた場合
ここまでの軸を潰し終えても直らないなら、残っているのは、プロセスに届いたバイト列がプロバイダーを出たバイト列と別物だという可能性だ。
圧縮が挟まっていることがある。プロバイダーやプロキシが Content-Encoding: gzip を付けて、gzip 圧縮したボディを送ってくる場合だ。署名の対象は非圧縮のペイロードなので、展開してからハッシュしなければならない。透過的に展開するフレームワークもあれば、圧縮されたバイト列をそのまま渡してくるものもある。ログでボディがバイナリのゴミに見えていたら、それが手がかりだ。
チャンク転送も疑う。Transfer-Encoding: chunked では Content-Length が存在せず、読み取りバッファのサイズをそのヘッダーに任せているコードはボディを切り詰める。切り詰められたボディのダイジェストは、正しく計算された無意味な値だ。絶対に一致しないのに、どこも壊れて見えない。
プロキシや WAF のように、ボディを読んで書き換える層はどれもバイト列を変えられる。AWS API Gateway は Lambda に届く前にボディを base64 エンコードすることがあるので、ハッシュ前にデコードが必要になる。アプリケーションロードバランサー、サービスメッシュ、Web アプリケーションファイアウォールのいずれも、ペイロードを正規化したり再エンコードしたりすることがある。確かめ方は、ハンドラーが見ているバイト数と、プロバイダーが送った Content-Length を比べることだ。
ペイロードには非 ASCII 文字が入りうるし、GitHub のドキュメントはペイロードを UTF-8 として扱うことを明記している。誤った文字セットでボディを文字列にデコードして再エンコードすれば、マルチバイト文字はすべて壊れる。親切なエディターやシリアライザーが先頭に付けた UTF-8 のバイトオーダーマーク EF BB BF は、署名されていない 3 バイトを足すことになる。
テキストモードのファイル境界を通ったボディは、LF が CRLF に書き換えられて届くことがある。署名対象の正確な文字列についてはプロバイダーの仕様も読むこと。独自の文字を末尾に足すところもあり、Typeform は末尾の改行がハッシュ対象に含まれる文書化された事例だ。プロバイダーのドキュメントが余分な文字に触れていたら、字面どおりに受け取る。
9. 繰り返し使えるデバッグ手順
上から順に実行する。各ステップはバグを見つけるか、分岐を一つ消すかのどちらかで、途中で止まればそれが成果だ。
- どのミドルウェアも走る前に生のバイト列をログに出す。 ボディをファイルに書き出すか、そのバイト数と SHA-256 を、リクエストのライフサイクルで手が届くいちばん早い地点から出力する。長さだけで解決する例は意外に多い。期待より 1 大きければ末尾の改行、3 大きければ BOM だ。
- ダイジェストを手で計算する。 そのバイト列とシークレットを HMAC ジェネレーターに貼り付け、SHA-256 を選び、出力フォーマットをヘッダーに合わせる。問題をきれいに二分できるステップだ。
- 手で計算した値をヘッダーと比べる。 一致すればバイト列もシークレットも正しく、バグはコードのどこかにあるので、比較処理を読みに行く。一致しなければ入力のどれかが間違っているので、次へ進む。
- 署名対象の文字列をセクション 2 の表と照合する。 このプロバイダーはタイムスタンプを前に付けるか。区切り文字は何か。ツール側で接頭辞を足して再計算する。
- ダイジェストのエンコードを切り替える。 hex と base64 の両方で計算し直し、どちらもヘッダーと比べる。末尾に
=が付いた 44 文字のヘッダー値は、コードが何を仮定していようと base64 だ。 - 鍵のエンコードを切り替える。 シークレットをテキスト、hex、base64 の順に試す。3 つのうちどれかで一致するのがふつうで、それがプロバイダーの期待している形を教えてくれる。
- 時計とローテーションの状態を確認する。 サーバーの時刻を信頼できるソースと比べ、エポック秒を扱っていることを確かめ、直近 24 時間にローテーションがなかったかをプロバイダーのダッシュボードで見る。
このループを速くする習慣が 2 つある。1 つ目は、失敗したペイロードを 1 件キャプチャして、次の配信を待たずにオフラインで作業すること。2 つ目は、そのキャプチャしたボディを固定した署名とともに自分のエンドポイントへ再送し、試行ごとに入力が変わらないようにすることだ。cURLコマンドジェネレーターは、指定どおりのヘッダーとファイルから読んだボディでリクエストを組み立てるので、何度実行してもバイト列がぶれない。好きなタイミングで失敗を再現できれば、散発的な webhook signature verification failed の報告も一度で切り分けられる。
それでもサポートに問い合わせるなら、ハッシュしたボディのバイト数、ヘッダー値の原文、組み立てた署名対象文字列の構成、ダイジェストのエンコードを書き添える。シークレットそのものは絶対に入れない。
FAQ
ローカルでは通る Webhook 署名が、本番で失敗するのはなぜ?
テスト用のペイロードはおそらく JSON の round-trip でバイト列が変わらないので、再シリアライズしても害がない。本物のペイロードには浮動小数点数、大きな整数、Unicode エスケープ、余分な空白が入っていて、これらはバイト列を変える。再シリアライズしたコピーではなく生のボディに署名すること。どの形が壊れるかはセクション 3 の表にある。
署名を比較するとき sha256= の接頭辞は含めるべき?
外すか、自分の値にも付けて両方の文字列を完全に一致させる。計算した hex ダイジェストは 64 文字で、接頭辞付きのヘッダー値は 71 文字だ。比較関数によっては長さの不一致で false を返すが、Node の timingSafeEqual は false を返さずに例外を投げる。
フレームワークが JSON をパースしたあとでも署名を検証できる?
確実にはできない。再シリアライズが元のバイト列を再現するのは、浮動小数点数がなく、2^53 を超える整数がなく、Unicode エスケープもなく、余分な空白もないペイロードに限られる。どれか一つ現れた瞬間にダイジェストは変わるので、テストでは通り、本番イベントの一部で落ちる。
同じペイロードなのに Stripe と GitHub で署名が違うのはなぜ?
ハッシュしている文字列が違うからだ。GitHub は生のボディだけに署名する。Stripe はタイムスタンプ、リテラルの .、そのあとにボディを署名するので、同じペイロードでも配信時刻が違えばダイジェストは 2 つに分かれる。Slack は v0: と自前のタイムスタンプを前に付ける。アルゴリズムは同じで、入力が違う。
タイムスタンプの許容幅はどれくらいにすべき?
Stripe と Slack が使っているのは 5 分で、これを踏襲するのは妥当なデフォルトだ。窓が短いと、サーバーの時計がずれた時点で正当な配信を弾き始める。長ければ、キャプチャされたリクエストを再生できる期間が広がる。許容幅を緩める前に NTP で時刻を合わせること。
timingSafeEqual は長さが違うと false を返す?
返さない。Node は RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length を投げる。捕まえなければ 401 ではなく 500 になり、比較の 1 行上ではなくハンドラーを疑ってしまう。先に長さを比べて、自分で false を返すこと。
プロバイダーがシークレットをローテーションしたのに、一部の Webhook が失敗するのはなぜ?
ローテーションの窓は重なる。Stripe は古いシークレットを最大 24 時間有効に保ち、有効なシークレットごとに v1 署名を 1 つ送るので、最初の v1 しか読まないコードはおよそ半分のイベントで落ちる。Shopify は新しいシークレットを使い始めるまで最大 1 時間かかることがある。
まとめ
検証はバイト列の比較なので、webhook signature verification failed の原因は暗号ではなくバイト列の食い違いにある。デバッグ中は次の軸を混ぜないこと。
- どのバイト列に署名されたか。パーサーが触る前に生のボディを確保する。再シリアライズしたオブジェクトをハッシュしてはいけない。テストは通っても本番のペイロードで落ちるからだ。
- どの鍵バイトを使ったか。1 つのシークレットをテキスト、hex、base64 として読めば、鍵は 3 通りになる。
- どのエンコードで比較したか。hex は 64 文字、base64 は 44 文字で、どちらも同じ 32 バイトを表す。
- それ以外すべて。タイムスタンプの接頭辞、値の接頭辞、許容幅、ローテーションの重複期間、トランスポート層。だいたいこの順に可能性が高い。
- どう比較したか。長さをガードしてから、ランタイムの定数時間比較関数を使う。
信頼して突き合わせられる値が欲しいときは、アプリケーションの外で計算する。ボディとシークレットを HMAC ジェネレーターに貼り付けて、どちら側が間違っているかを教えてもらえばいい。