Webhook 署名検証が body パースで壊れる問題と、生リクエストボディの取得(Astro / Cloudflare Workers)
Slack や Stripe、GitHub などの Webhook は、リクエストが本物かを確認するために署名検証(HMAC)を行います。この検証は「送られてきた生のリクエストボディ(バイト列)」に対して計算されます。ところが、フレームワークや CMS がリクエストボディを先にパースしてしまうと生ボディが取れなくなり、署名検証が通らなくなります。ハマりやすい罠なので、原因と Astro / Cloudflare Workers での対処をまとめます。
前提バージョン: 本記事のコードは Astro 7 系 / Cloudflare Workers(module worker 構文)を想定しています。Astro の例は on-demand rendering(SSR。output: "server" などサーバーレンダリング構成のルート)が前提です。使用する API はいずれも Web 標準の `Request` なので、Astro 3 以降でおおむね共通です。
なぜ「生ボディ」でないとダメなのか
署名は送信側が送ったそのままのバイト列に対して HMAC-SHA256 を計算します。受信側でボディを一度 JSON にパースし、それを JSON.stringify で再シリアライズしても、キーの順序や空白が変わってバイト列が一致せず、署名が合いません。だから「パース済みのオブジェクト」ではなく「生のボディ文字列」が必要です。
Slack も Stripe も、公式ドキュメントで「body をパースするミドルウェアが署名検証を壊す」と明記しています。
症状
典型は Express の express.json()。これを通すと req.body はパース済みで、生ボディが手元に残りません。同じことが Astro や Next.js、CMS の body パースでも起きます。「署名検証が常に失敗する」ときは、まずどこかで body がパースされていないかを疑ってください。
Astro(API エンドポイント)での取得
Astro の エンドポイント は、標準の Web Request をそのまま渡します。src/pages/api/... のルートで request.text() を呼べば生ボディが読めます。
// src/pages/api/slack/events.ts
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request }) => {
const rawBody = await request.text(); // 生ボディ(署名検証用)
const sig = request.headers.get("x-slack-signature");
const ts = request.headers.get("x-slack-request-timestamp");
if (!verifySlackSignature(rawBody, ts, sig, signingSecret)) {
return new Response("invalid signature", { status: 401 });
}
const payload = JSON.parse(rawBody); // 検証してから自分でパース
// ...
return new Response("ok");
};ポイントは、フレームワークにパースさせず、自分で `request.text()` → 検証 → `JSON.parse` の順にすることです。
Cloudflare Workers での取得
Workers も同じく Web 標準の Request です。fetch ハンドラで await request.text() すれば生ボディが取れます。
export default {
async fetch(request: Request): Promise<Response> {
const rawBody = await request.text();
// 署名検証 → JSON.parse
return new Response("ok");
},
};注意:body ストリームは一度しか読めない
Request の body は一度読むと再度読めません。パースと署名検証の両方で body が必要なら、次のどちらかにします。
- 生ボディを
request.text()で読み、検証後に自分で `JSON.parse` する(上の例)。 - どうしても両方の API を使いたいなら、`request.clone()` で複製してから片方を読む。
フレームワーク / CMS が先に body を消費してしまう場合
問題が根深いのは、フレームワークや CMS のルーティングが handler を呼ぶ前に body をパースし、生ボディを露出しないケースです。この場合の選択肢:
- 署名検証エンドポイントだけ、フレームワークの body パースを迂回する(プレーンな Web ハンドラや別ルートに置く)。
- フレームワーク側に生ボディを露出する仕組み(ルートオプションやパッチ)を用意する。
いずれにせよ原則は同じで、「署名検証は body がパースされる前の生バイト列で行う」です。
まとめ
- Webhook 署名検証は生のリクエストボディに対する HMAC。パース→再シリアライズでは一致しない。
- Astro / Cloudflare Workers は標準
Requestなのでawait request.text()で生ボディを取れる。自分でパースする前に検証する。 - body は一度しか読めない。両方要るなら
request.clone()。 - フレームワーク / CMS が先に body を消費する場合は、そのルートだけパースを迂回するか、生ボディを露出させる。