Webhook を受け取る側で署名検証を実装すると、「ドキュメント通りに HMAC-SHA-256 を計算したのに一致しない」という状態に一度は陥る。原因はほとんどの場合、HMAC そのものではなく、何を入力にしたかと鍵をどう読んだかにある。
この記事では、GitHub の Webhook 署名の仕様を一次情報で確認し、Node.js で RFC 4231 のテストベクタを再現して計算の正しさを固定したうえで、検証が落ちる典型パターンを実際に起こして確かめる。
仕様で確認できたこと
GitHub の公式ドキュメント「Validating webhook deliveries」で確認できた内容は次のとおり。
| 項目 |
内容 |
| ヘッダ名 |
X-Hub-Signature-256 |
| 値の形式 |
sha256= に続けて16進のダイジェスト |
| アルゴリズム |
Webhook のシークレットを鍵にした HMAC-SHA-256 |
| 署名の対象 |
受信した生のリクエストボディ(加工しない。UTF-8 として扱う) |
| 比較方法 |
== を使わず、crypto.timingSafeEqual のような定数時間の比較関数を使う |
HMAC 自体の定義は RFC 2104、テストベクタは RFC 4231 にある。他のサービスは別のヘッダ名や Base64 表記を使うことがあるので、実装前に送信側の仕様を必ず確認する。
実際に試す
前提は Node.js(ここでは Windows 11 上の v24.11.1)。外部パッケージは使わず、標準の node:crypto だけで書く。次を verify-webhook.mjs として保存する。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { createHmac, timingSafeEqual } from 'node:crypto';
export function sign(rawBody, secret) { return 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'); }
export function verify(rawBody, secret, header) { const expected = Buffer.from(sign(rawBody, secret)); const received = Buffer.from(String(header ?? '')); if (expected.length !== received.length) return false; return timingSafeEqual(expected, received); }
|
1. RFC 4231 のテストベクタで計算を固定する
別ファイル check.mjs に、次を書いて node check.mjs で動かす。
1 2 3 4
| import { createHmac } from 'node:crypto'; const k1 = Buffer.alloc(20, 0x0b); console.log(createHmac('sha256', k1).update('Hi There').digest('hex')); console.log(createHmac('sha256', 'Jefe').update('what do ya want for nothing?').digest('hex'));
|
実測した出力は次のとおり。1行目は RFC 4231 のテストケース1の HMAC-SHA-256 として知られる値と一致した。
1 2
| b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
|
自分の環境で計算が正しいことをこれで先に確かめておくと、後で一致しないときに「計算のせいではない」と切り分けられる。ブラウザで同じ値を見たいときは HMAC 計算(ハシトシステム) のサンプルに RFC 4231 の1番目と2番目が入っている。
2. JSON を整形し直すと検証が落ちる
署名は「送信側が送ったバイト列」に対して作られる。受信側で一度 JSON.parse して JSON.stringify し直すと、空白が変わって別のメッセージになる。
1 2 3 4 5 6 7
| import { sign, verify } from './verify-webhook.mjs'; const secret = "It's a Secret to Everybody"; const raw = '{"action":"opened", "number":1}'; const header = sign(raw, secret);
console.log(verify(raw, secret, header)); console.log(verify(JSON.stringify(JSON.parse(raw)), secret, header));
|
内容は同じ JSON でも、再整形した文字列は {"action":"opened","number":1} になり、署名は一致しない。Express などのフレームワークでは、JSON パーサより前に生のボディを保持する設定が要る。
3. 鍵を UTF-8 として読むか、16進として読むか
同じ文字列でも、鍵の解釈で結果が変わる。
1 2 3
| const hexKey = '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'; console.log(createHmac('sha256', hexKey).update('Hi There').digest('hex')); console.log(createHmac('sha256', Buffer.from(hexKey, 'hex')).update('Hi There').digest('hex'));
|
1 2
| 0dff03eeb5bca6b9fd6b52d08cfc8ac04e169a3d0233fbff72b5b844fba0f96b b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7
|
2行目だけが RFC 4231 の値になる。仕様書が「16進の鍵」と書いているなら Buffer.from(key, 'hex') で渡す。
4. timingSafeEqual は長さ違いで例外を投げる
1 2 3
| import { timingSafeEqual } from 'node:crypto'; try { timingSafeEqual(Buffer.from('ab'), Buffer.from('abc')); } catch (e) { console.log(e.code); }
|
1
| ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
|
false が返るのではなく例外になるので、不正なヘッダを送られると500エラーになりうる。冒頭の verify で先に長さを比べているのはこのためだ。ヘッダ欠落(undefined)や短すぎる値も、実測で false になった。
5. 受信サーバーで通しで確かめる
server.mjs として次を保存する。ポート0で起動し、自分自身に3回 POST して終了する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import http from 'node:http'; import { sign, verify } from './verify-webhook.mjs'; const SECRET = "It's a Secret to Everybody";
const server = http.createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const raw = Buffer.concat(chunks); const ok = verify(raw, SECRET, req.headers['x-hub-signature-256']); res.statusCode = ok ? 200 : 401; res.end(ok ? 'ok' : 'bad signature'); }); });
server.listen(0, async () => { const url = `http://127.0.0.1:${server.address().port}/`; const body = '{"action":"opened", "number":1}'; const post = (b, sig) => fetch(url, { method: 'POST', body: b, headers: { 'x-hub-signature-256': sig } }).then((r) => r.status); console.log('正しい署名 :', await post(body, sign(body, SECRET))); console.log('ボディ改変 :', await post(body.replace('1', '2'), sign(body, SECRET))); console.log('ヘッダ無し :', await fetch(url, { method: 'POST', body }).then((r) => r.status)); server.close(); });
|
1 2 3
| 正しい署名 : 200 ボディ改変 : 401 ヘッダ無し : 401
|
一致しないときの切り分け順
- RFC 4231 のベクタで自分の計算が合うか(合わなければ実装の問題)。
- 入力は受信した生のバイト列か(パーサ通過後の文字列ではないか)。
- 鍵の解釈(UTF-8 / 16進 / Base64)は仕様どおりか。
- 出力の表記(16進か Base64 か、
sha256= の接頭辞の有無)は送信側と同じか。
手元で素早く試すなら、ブラウザ内で完結する HMAC 計算 に生ボディとシークレットを貼り、受信した署名を照合欄に入れると、16進と Base64 のどちらで一致したかまで分かる。ただし本番のシークレットを共有端末に貼るのは避けること。
参考
- GitHub Docs, Validating webhook deliveries
- RFC 2104 (HMAC) / RFC 4231 (HMAC-SHA-2 のテストベクタ)
- Node.js ドキュメント crypto(
createHmac, timingSafeEqual)