ガイド

ガイド3 分で読めます

フォームの Webhook 署名を検証する

フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。

Preet SutharIkiform 創業者

署名を自分で計算し直し、リクエストに含まれる署名と比較します。Ikiform は Standard Webhooks に準拠しています。署名は、webhook-id、webhook-timestamp、生のボディをドットでつないだ文字列の HMAC-SHA256 を base64 にしたもので、鍵には whsec_ シークレットの base64 デコードした部分を使い、v1,<signature> の形で送られます。定数時間の比較でいずれかの v1 署名が一致し、タイムスタンプが 5 分以内のときだけリクエストを受け付けてください。

Webhook のエンドポイントは、誰からの POST リクエストでも受け付ける公開 URL です。署名を確認しなければ、URL を知った人なら誰でも、偽の送信データを CRM やデータベース、チームのチャットに送り込めます。確認のコードは数行で、その数行を正確に書くことがこのガイドのテーマです。

署名の仕組み

Ikiform とあなたのサーバーは 1 つのシークレットを共有します。シークレットはエンドポイントを追加したときに 1 回だけ表示されます。Ikiform は配信を試みるたびに、そのシークレットでリクエストのハッシュを計算し、結果をヘッダーに入れます。サーバーは受け取った内容で同じ計算を繰り返します。一致する値を作れるのはシークレットを持つ人だけで、ボディ、ID、タイムスタンプのどれかが変わると一致しなくなります。

Ikiform が Webhook の配信に署名する方法と、サーバーがそれを確認する方法Ikiform、毎回の試行であなたのハンドラーJSON ボディsubmission.createdid.timestamp.body署名される内容HMAC-SHA256有効なシークレットごとにwebhook-signaturev1,<base64>生のボディ +ヘッダー解析する前に同じ HMAC手元のシークレットで定数時間で比較v1が一致、タイムスタンプが新しい2xxそれから解析して処理400何も処理しない一致なしIkiform が Webhook の配信に署名する方法と、サーバーがそれを確認する方法Ikiform、毎回の試行であなたのハンドラーJSON ボディsubmission.createdid.timestamp.body署名される内容HMAC-SHA256有効なシークレットごとにwebhook-signaturev1,<base64>生のボディ + ヘッダー解析する前に同じ HMAC手元のシークレットで定数時間で比較v1 が一致、タイムスタンプが新しい2xxそれから解析して処理400何も処理しない一致なし
  1. Ikiform がイベントを JSON にシリアライズします。
  2. 配信 ID、その試行の Unix タイムスタンプ、ボディをドットでつなぎます。
  3. その文字列の HMAC-SHA256 を、エンドポイントの有効なシークレットごとに計算します。
  4. 各結果を base64 エンコードして v1 を前に付け、webhook-signature ヘッダーで送ります。
  5. ハンドラーは、何かを解析する前に生のボディと 3 つのヘッダーを読みます。
  6. 手元のシークレットで同じ HMAC を計算します。
  7. すべての v1 署名と定数時間で比較し、タイムスタンプが 5 分以内であることを確認します。
  8. 一致すれば 2xx を返してイベントを処理します。一致しなければ 400 を返し、何もしません。
リクエストと一緒に秘密の情報が送られることはありません。署名は、送信者がシークレットを知っていたこと、そしてバイト列が途中で変更されていないことを証明します。

方式の詳細

Ikiform は、複数の Webhook 提供元が採用しているオープンな仕様 Standard Webhooks を使っています。すべての配信には 3 つのヘッダーが付きます。

ヘッダー値注意
webhook-iddlv_...配信 ID。リトライでも毎回同じなので、冪等性キーとしても使えます
webhook-timestampUnix 秒。例: 1792300000この試行に署名した時刻。リトライのたびに新しくなります
webhook-signaturev1, と base64スペース区切りの 1 つ以上の値。シークレットのローテーション中は 2 つ
署名される内容
webhook-id:        dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body:          {"id":"dlv_01k7...","type":"endpoint.test",...}

signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key            = base64-decode(secret without "whsec_")
signature      = base64(HMAC-SHA256(key, signed content))
header         = "v1," + signature

配信を検証する手順は、次の順番です。

  1. 生のボディをバイト列か元のままの文字列として読みます。まだ解析しないでください。
  2. 3 つのヘッダーを読みます。どれかが欠けていれば拒否します。
  3. タイムスタンプが整数で、自分の時計との差が前後 5 分以内であることを確認します。そうでなければ拒否します。
  4. シークレットから whsec_ を取り除き、残りを base64 デコードします。HMAC の鍵はその 32 バイトで、文字列そのものではありません。
  5. {webhook-id}.{webhook-timestamp}.{raw body} の HMAC-SHA256 を計算し、標準アルファベットとパディングで base64 エンコードします。
  6. webhook-signature をスペースで分割します。各要素を最初のカンマで分割し、バージョンが v1 でないものは飛ばします。
  7. 各 v1 の値を自分の値と定数時間で比較します。どれかが一致すれば、その配信は本物です。
  8. そこで初めて JSON を解析し、処理します。

テストベクター

ハンドラーをデプロイする前に、正しい値を計算できることを確かめましょう。以下の入力は、実際の endpoint.test の配信ボディを架空のシークレットで署名したものです。コードは最後の行の署名をそのまま再現しなければなりません。各言語のガイドには、これを使う単体テストが付いています。

入力値
シークレットwhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
生のボディ下のスクリプトにあるとおりのバイト列。スペースや改行はなし
期待される webhook-signaturev1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=

コードを書かなくても確認できます。macOS か Linux のターミナルで実行すると、署名の base64 部分が表示されます。

ターミナル
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
BODY='{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'

KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
  | base64

実際によくある失敗はこの 2 つなので、それぞれテストしておく価値があります。

変更点署名ハンドラーは受け付けるべきか
同じボディを 2 スペースのインデントで整形したものv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=いいえ。JSON は同じでもバイト列が違います
1 秒後の webhook-timestampv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=いいえ。タイムスタンプも署名に含まれます
ローテーション中に 2 つ目のシークレットで署名したものv1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=はい。ヘッダーに自分のシークレットと一致する値も含まれていれば

シークレットのローテーション中、ヘッダーには両方の値が入ります。どちらのシークレットを持つハンドラーでも、これを受け付けなければなりません。

2 つの署名
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
ベクターのタイムスタンプは固定なので、実際の検証処理では古すぎるとして拒否されます。テストでは固定した現在時刻を渡す必要があり、各言語のガイドはそうしています。Webhooks のページにある署名検証ツールでは、任意のシークレット、ヘッダー、ボディをブラウザーで確認できます。

検証の方法

方法得意なことトレードオフ
@ikiform/sdk/webhooks の verifyWebhook1 回の呼び出しですべてを確認し、型付きのイベントを返します。Node、Workers、Deno、Bun で動きますJavaScript と TypeScript のみ
Standard Webhooks のライブラリPython、Go、PHP、Ruby、JavaScript、Rust、Java、C#、Elixir 向けにメンテナンスされたコード依存パッケージが 1 つ増えます。ヘッダーの渡し方はライブラリごとに確認してください。大文字と小文字を区別するものもあります
自前の HMAC コード依存なし、約 20 行で、すべての分岐をテストできます文字列比較、間違った鍵のバイト列、解析済みのボディなど、気づきにくい誤りをしやすい
代わりに秘密のパスやクエリトークンを使うシンプルログ、プロキシ、エラートラッカーから漏れ、ボディについて何も証明せず、有効期限も設けられません。代わりにはなりません

Ikiform はすべての配信に署名しており、配信を認証する方法として公式にサポートしているのは署名です。送信元 IP アドレスの固定リストはドキュメントで公開していないので、IP の許可リストは頼れる確認方法ではありません。

フレームワークで検証する

JavaScript のスタックなら、SDK が処理を引き受けます。フレームワークごとに違うのは、ルートを置く場所と、何かに解析される前にボディを取得する方法です。自分のものを選んでください。

App Router のルートハンドラーです。request.text() が生のボディです。先に request.json() を呼ぶと失われます。自前サーバーのガイドでは、重複排除と after() を追加しています。

app/api/webhooks/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    console.info(event.type, request.headers.get("webhook-id"));
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}

どのハンドラーも、成功時は 204 を、それ以外は失敗理由を付けて 400 を返します。時間のかかる処理は応答のあとに行ってください。Ikiform は 15 秒待っても応答がなければ、その試行を失敗とみなします。

生のボディが重要な理由

署名が対象にするのは意味ではなくバイト列です。{"a":1} と { "a": 1 } は JSON としては同じでもバイト列は違うので、一度解析して再シリアライズしたボディはまず一致しません。受信の途中でボディに手を加えるものはすべて同じです。JSON を解析するミドルウェア、再エンコードするプロキシ、文字列ではなくオブジェクトを渡してくるフレームワークなどです。

ボディはバイト列か文字列として 1 回だけ読み、検証してから、その同じ文字列を解析します。各言語のガイドでは、それぞれのフレームワークで生のボディがどこにあるかを示しています。自前サーバーのガイドには、よく使われる Node と Python のフレームワークの表があります。

定数時間で比較する

通常の文字列比較は、最初に違う文字が見つかった時点で止まります。理論上は、何千ものリクエストの時間を測れる攻撃者なら、推測の先頭何文字が正しいかを知り、有効な署名を 1 文字ずつ組み立てられます。定数時間の比較は、どれだけ一致していても同じ時間がかかります。どの言語にも用意されています。

言語定数時間の比較
Node.jscrypto.timingSafeEqual(a, b)。長さが違うと例外を投げるので、先に長さを確認します
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.secure_compare(a, b)、Rails なら ActiveSupport::SecurityUtils.secure_compare

タイムスタンプとリプレイ

一度キャプチャされた有効なリクエストは、何かで期限を切らない限り永遠に有効なままです。その役目を果たすのが署名済みのタイムスタンプです。自分の時計との差が前後 5 分を超える配信はすべて拒否してください。Ikiform は試行のたびに新しいタイムスタンプで署名し直すので、3 日後のリトライでも通ります。失敗するのは、コピーされた古いリクエストだけです。

その 5 分の間は、リプレイも、本物の重複も起こりえます。Ikiform は時間内に 2xx が返らないと、ハンドラーの処理が終わっていてもリトライします。webhook-id を一意制約付きで保存し、処理済みの ID は飛ばしてください。これで両方を防げます。

サーバーの時計は同期させておいてください。5 分を超えてずれた時計では、すべての配信が timestamp_too_old か timestamp_too_new で拒否されます。マネージドなプラットフォームは通常時計を同期していますが、自分の VM では NTP を動かしてください。

シークレットのローテーション

シークレットを知っていた人が抜けたとき、ログに残ったりコミットされたりした可能性があるとき、または定期的に、シークレットをローテーションします。エンドポイントのページで Roll signing secret を選ぶと新しいシークレットが作られ、その後 24 時間はすべての配信が両方で署名されます。

シークレットのローテーション前、最中、後に webhook-signature に入る内容前v1,<old>ローテーション新しいシークレットは1 回だけ表示次の 24 時間v1,<old>v1,<new>後v1,<new> のみ新しいものをデプロイシークレットのローテーション前、最中、後に webhook-signature に入る内容前v1,<old>ローテーション新しいシークレットは 1 回だけ表示次の 24 時間v1,<old> v1,<new>後v1,<new> のみ新しいものをデプロイ
  1. ローテーション前は、各配信に古いシークレットで作った署名が 1 つ付きます。
  2. Roll signing secret を選び、新しいシークレットをコピーします。
  3. その後 24 時間は各配信に署名が 2 つ付くので、どちらのシークレットを持つサーバーでも受け付けられます。この間に新しいシークレットをデプロイします。
  4. 24 時間後は新しい署名だけが付き、古いシークレットのままのサーバーでは拒否されます。
最初の 1 つだけでなく、すべての v1 エントリーを確認していれば、ハンドラーに特別なコードは要りません。

24 時間経っても古いシークレットがデプロイされたままだと、すべての配信が失敗します。Ikiform は各配信を約 3 日かけて 8 回リトライするので、正しいシークレットをデプロイしたら、すでにあきらめられたものは配信ログの Retry で再送してください。

確認が通ったら

有効な署名が証明するのは、リクエストが Ikiform から来て、変更されていないことです。内容が安全であることや、新しいことは証明しません。仕上げに 3 つの習慣を身につけましょう。

  • 重複を飛ばす。 webhook-id、または同じ値であるペイロードの id を一意制約付きで保存します。処理済みの配信のリトライは何もしない操作になります。
  • 先に応答し、処理はあとで。 イベントを保存するかキューに入れたら、すぐに 2xx を返します。Ikiform は 15 秒より遅い応答を失敗とみなしてリトライするので、重複の問題が戻ってきます。
  • フィールドは信頼しない。 data.fields には訪問者が公開フォームに入力した内容が入っています。HTML、チャットのメッセージ、メールに表示する前にエスケープし、シェルやパラメーター化していないクエリには絶対に渡さないでください。

本番公開前にテストする

Ikiform はポート 443 の公開 https: URL にしか配信しないので、localhost では Webhook を受け取れません。cloudflared tunnel --url http://localhost:3000 などのトンネルを動かし、表示された URL を作業中だけ 2 つ目のエンドポイントとして追加してください。エンドポイントのページの Send test は endpoint.test イベントを 1 回だけ、リトライなしで、本物の配信とまったく同じ署名を付けて送ります。終わったらテスト用のエンドポイントを削除してください。シークレットは別なので、ほかには何も変わりません。

配信が失敗して理由がわからないときは、ログからヘッダーとボディをコピーして署名検証ツールに貼り付けてください。ブラウザーで同じ確認を行い、どの手順で失敗したかを教えてくれます。

検証でよくある 5 つの間違い

間違い症状対処
whsec_... の文字列全体を鍵に使っているすべての配信で no_matching_signaturewhsec_ を取り除いてから base64 デコードします。鍵は 32 バイトです
解析済みのボディを検証しているno_matching_signature。一部のペイロードだけで起きることも生のボディを検証し、そのあとで解析します
最初の署名だけを確認しているローテーション直後から始まる失敗スペース区切りのすべての v1 エントリーをループで確認します
base64 ではなく 16 進数を使っているno_matching_signatureStandard Webhooks が使うのはパディング付きの標準 base64 で、16 進数や base64url ではありません
ヘッダーを大文字・小文字まで完全一致で探している一部のプロキシの背後で missing_headersヘッダー名は大文字と小文字を区別しません。Ikiform は小文字で送ります。大文字・小文字を区別せずに探してください

ワンクリックの連携はありません。どのツールにも、署名付きの Webhook で直接、または Zapier や n8n などの自動化ツールを経由して届けます。1 つのワークスペースでエンドポイントを 3 つまで持て、それぞれに専用のシークレットがあります。ペイロードの全体、リトライのスケジュール、配信ログについては Webhooks を見てください。

状況に合わせて選ぶ

各言語のページには、その言語らしいハンドラー、ライブラリを使う方法、このページのテストベクターを実行する単体テストがあります。

動作を確認する

まずステージングのエンドポイントで、次に本番でもう一度行ってください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。料金を参照してください。

  1. テストベクターを自分のコードで実行します。期待どおりの署名が出て、2 つの署名を含むヘッダーを受け付け、整形されたボディを拒否するはずです。
  2. ダッシュボードで Webhooks を開き、エンドポイントの公開 https: URL を追加して、whsec_ のシークレットをサーバーの環境変数にコピーします。
  3. エンドポイントのページで Send test を選びます。配信ログに 204 が表示され、イベントがログに届きます。
  4. サーバー上のシークレットを 1 文字だけ変えて、もう一度テストを送ります。ログに no_matching_signature 付きの 400 が表示されます。
  5. 実際にフォームを送信し、submission.created の配信が成功することを確認します。

トラブルシューティング

問題対処法
400 no_matching_signatureシークレットが違う、ボディが解析済み、または鍵が base64 デコードされていません。どれが原因かは、同じコードパスにテストベクターを通して確かめてください。
400 timestamp_too_old や timestamp_too_newサーバーの時計がずれているか、保存したリクエストを手動で再送しています。時計を同期し、配信ログの Retry を使ってください。こちらは署名し直して送ります。
400 missing_headersプロキシかフレームワークが webhook-* ヘッダーを落としたか、リクエストが Ikiform から来たものではありません。ハンドラーに届くヘッダー名をすべてログに出してください。
テストイベントでは動くのに、実際の送信データで失敗する実際のペイロードには非 ASCII 文字や長いテキストが含まれます。コードが検証前にボディをデコードまたは再エンコードしています。受け取ったバイト列そのものを検証してください。
シークレットをローテーションした直後から、すべての配信が失敗するハンドラーが最初の署名しか確認していないか、古いシークレットをデプロイしたまま 24 時間が過ぎています。すべての v1 エントリを確認し、新しいシークレットをデプロイしてください。
配信ログにはタイムアウトと出ているのに、ハンドラーは実行されているIkiform は 15 秒待ちます。先に応答を返して時間のかかる処理は後で行い、重複した webhook-id の値はスキップしてください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com