Webhook署名のHMAC-SHA256検証:生ボディとtimingSafeEqualの罠

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
// verify-webhook.mjs
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; // timingSafeEqual は長さ違いで例外
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}'; // カンマの後に空白が2つ
const header = sign(raw, secret);

console.log(verify(raw, secret, header)); // 生ボディ
console.log(verify(JSON.stringify(JSON.parse(raw)), secret, header)); // 再整形
1
2
true
false

内容は同じ 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

一致しないときの切り分け順

  1. RFC 4231 のベクタで自分の計算が合うか(合わなければ実装の問題)。
  2. 入力は受信した生のバイト列か(パーサ通過後の文字列ではないか)。
  3. 鍵の解釈(UTF-8 / 16進 / Base64)は仕様どおりか。
  4. 出力の表記(16進か Base64 か、sha256= の接頭辞の有無)は送信側と同じか。

手元で素早く試すなら、ブラウザ内で完結する HMAC 計算 に生ボディとシークレットを貼り、受信した署名を照合欄に入れると、16進と Base64 のどちらで一致したかまで分かる。ただし本番のシークレットを共有端末に貼るのは避けること。

参考

  • GitHub Docs, Validating webhook deliveries
  • RFC 2104 (HMAC) / RFC 4231 (HMAC-SHA-2 のテストベクタ)
  • Node.js ドキュメント crypto(createHmac, timingSafeEqual)

Webhook署名のHMAC-SHA256検証:生ボディとtimingSafeEqualの罠
https://blog.hashito.biz/2026/10/24/webhook-hmac-sha256-raw-body-timingsafeequal-node-rfc4231/
著者
hashito
作成日
2026年10月24日
著作権