Webhook の署名を検証する2 分で読めます
Ruby と Rails で Webhook 署名を検証する
Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。
生のボディ(Sinatra では request.body.read、Rails では request.raw_post)と 3 つの webhook-* ヘッダーを読みます。base64 デコードした whsec_ のシークレットをキーに OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") を計算して base64 エンコードし、OpenSSL.secure_compare で各 v1 署名と比較します。Rails では、standardwebhooks gem の Webhook#verify がチェック全体を行います。
Sinatra には依存関係のない検証関数と Minitest のテストを、Rails には API コントローラーで使う standardwebhooks gem と、繰り返しに対応するジョブを用意します。どの検証関数もどちらのフレームワークで動き、すべて Rack の上にあるので Hanami や Roda でも動きます。
Ruby で異なる点
Rack はすべてのリクエストヘッダーを環境変数のキーに変えます。webhook-id は HTTP_WEBHOOK_ID になります。Rails はこれを request.headers["webhook-id"] の裏に隠し、名前を対応づけてくれます。Sinatra はしないので、request.env["webhook-id"] は常に nil で、nil のヘッダーを受け取った検証関数は、Ikiform が送っていても missing_headers で失敗します。Rails にはボディの落とし穴の独自版もあります。application/json のコンテンツタイプなので JSON のボディを params に解析し、wrap_parameters がキーをコントローラー名の下にコピーすることもあります。どちらも request.raw_post は変えません。検証すべき値はこれだけです。
- Ikiform は小文字の webhook-id ヘッダーを送ります。
- Sinatra では、Rack がそれをリクエスト環境に HTTP_WEBHOOK_ID として保存するので、そこから読みます。
- Sinatra の request.body.read は生のボディを返し、JSON.parse の前にそれを検証します。
- Rails では、request.headers がヘッダー名を Rack のキーに対応づけてくれ、request.raw_post が gem で検証する生のボディを返します。
Sinatra と OpenSSL
require "openssl"
module IkiformSignature
class Invalid < StandardError; end
TOLERANCE = 5 * 60
def self.verify!(body, headers, secret, now: Time.now.to_i)
id = headers["webhook-id"].to_s
timestamp = headers["webhook-timestamp"].to_s
signatures = headers["webhook-signature"].to_s
raise Invalid, "missing_headers" if id.empty? || timestamp.empty? || signatures.empty?
raise Invalid, "invalid_timestamp" unless timestamp.match?(/\A[0-9]{1,12}\z/)
age = now - timestamp.to_i
raise Invalid, "timestamp_too_old" if age > TOLERANCE
raise Invalid, "timestamp_too_new" if age < -TOLERANCE
key = secret.delete_prefix("whsec_").unpack1("m0")
expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
matched = signatures.split(" ").any? do |entry|
version, value = entry.split(",", 2)
version == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
end
raise Invalid, "no_matching_signature" unless matched
end
endrequire "json"
require "sinatra"
require_relative "ikiform_signature"
SECRET = ENV.fetch("IKIFORM_WEBHOOK_SECRET")
post "/webhooks/ikiform" do
body = request.body.read
headers = {
"webhook-id" => request.env["HTTP_WEBHOOK_ID"],
"webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
"webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"]
}
begin
IkiformSignature.verify!(body, headers, SECRET)
rescue IkiformSignature::Invalid => error
halt 400, error.message
end
event = JSON.parse(body)
logger.info("submission #{event["id"]}") if event["type"] == "submission.created"
status 204
endunpack1("m0")とpack("m0")はbase64ライブラリなしで使える厳密な base64 です。このライブラリは Ruby 3.4 でデフォルト gem ではなくなり、使うなら Gemfile に追加する必要があります。OpenSSL.secure_compareは定数時間で比較し、Ruby 3.0 以降が必要です。先に両方の値をハッシュ化するので、長さが違っても問題ありません。- ASCII の ID とバイナリのボディから署名対象の文字列を組み立てても安全です。Ruby はボディのバイト列をそのまま保ちます。
- Sinatra は JSON のボディを解析しないので、
request.body.readが最初の読み込みです。このルートではRack::JSONBodyParserなどのミドルウェアを避けてください。 halt 400は理由をボディにしてルートを止め、その理由が Ikiform の配信ログに表示されるので、シークレットが間違っていればそこにno_matching_signatureと出ます。- Rack 3 ではリクエストボディを巻き戻せることが保証されなくなったので、1 回だけ文字列に読み込み、その文字列を検証と解析の両方に使ってください。
Minitest のテスト
require "json"
require "minitest/autorun"
require_relative "ikiform_signature"
class IkiformSignatureTest < Minitest::Test
SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
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."}}'
HEADERS = {
"webhook-id" => "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
"webhook-timestamp" => "1792300000",
"webhook-signature" => "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA="
}.freeze
NOW = 1_792_300_000 + 30
def test_accepts_the_vector
assert_nil IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW)
end
def test_accepts_either_secret_during_a_roll
both = HEADERS.merge("webhook-signature" => "#{HEADERS["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")
assert_nil IkiformSignature.verify!(BODY, both, SECRET, now: NOW)
assert_nil IkiformSignature.verify!(BODY, both, ROLLED, now: NOW)
end
def test_rejects_a_reserialized_body
pretty = JSON.pretty_generate(JSON.parse(BODY))
error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(pretty, HEADERS, SECRET, now: NOW) }
assert_equal "no_matching_signature", error.message
end
def test_rejects_a_stale_timestamp
error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW + 600) }
assert_equal "timestamp_too_old", error.message
end
endruby ikiform_signature_test.rb で実行します。テストベクターを固定した now で使います。Ruby でボディを再シリアライズするなら JSON.pretty_generate なので、そのケースでチェックがそれを検出することを示しています。Bundler を使うプロジェクトでは、Gemfile の test グループに minitest を追加してください。Ruby には bundled gem として同梱されていますが、Bundler は Gemfile にある gem しか読み込みません。
Rails と standardwebhooks gem
Gemfile に gem "standardwebhooks" を追加して bundle install を実行します。シークレットは bin/rails credentials:edit で ikiform.webhook_secret に保存するか、ENV から読みます。
class IkiformWebhooksController < ActionController::API
def create
webhook = StandardWebhooks::Webhook.new(Rails.application.credentials.dig(:ikiform, :webhook_secret))
event = webhook.verify(request.raw_post, {
"webhook-id" => request.headers["webhook-id"],
"webhook-timestamp" => request.headers["webhook-timestamp"],
"webhook-signature" => request.headers["webhook-signature"]
})
if event["type"] == "submission.created"
IkiformSubmissionJob.perform_later(event["id"], event.dig("data", "fields"))
end
head :no_content
rescue StandardWebhooks::WebhookVerificationError => error
render plain: error.message, status: :bad_request
end
endRails.application.routes.draw do
post "webhooks/ikiform", to: "ikiform_webhooks#create"
endActionController::APIには CSRF 保護がありません。コントローラーが代わりにApplicationControllerを継承しているならskip_forgery_protectionを追加してください。そうしないと Rails が送信を拒否します。request.raw_postは送られたままのボディで、最初に読んだあとはキャッシュされるので、2 回呼んでも安全です。params.to_jsonを検証してはいけません。Rails はすでに JSON を解析してparamsにマージしており、バイト列が異なります。- gem は小文字のキーちょうどのハッシュからヘッダーを読むので、示したとおりに組み立ててください。タイムスタンプとすべての
v1署名をチェックし、解析した JSON を返します。 perform_laterが処理をキューに入れ、head :no_contentがすぐに204を返すので、Ikiform の 15 秒に十分間に合います。ジョブはすでに見た配信 ID をスキップできます。
ジョブで繰り返しをスキップする
すぐに 204 を返して処理をジョブで行うのは正しい形ですが、ジョブが同じ配信を 2 回扱えなければなりません。Ikiform は 15 秒以内に応答を受け取れないと同じ webhook-id でリトライし、コントローラーは 2 つ目のジョブをキューに入れます。ユニークインデックスがあれば、2 つ目は何もしません:
class CreateWebhookDeliveries < ActiveRecord::Migration[8.0]
def change
create_table :webhook_deliveries do |t|
t.string :delivery_id, null: false
t.timestamps
end
add_index :webhook_deliveries, :delivery_id, unique: true
end
endclass IkiformSubmissionJob < ApplicationJob
queue_as :default
def perform(delivery_id, fields)
WebhookDelivery.transaction do
WebhookDelivery.create!(delivery_id: delivery_id)
SubmissionMailer.with(fields: fields).notify_team.deliver_now
end
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("Skipping repeated delivery #{delivery_id}")
end
end挿入と処理は 1 つのトランザクションにまとめているので、メール送信が失敗すると ID がロールバックされ、ジョブ自身のリトライがもう一度実行されます。RecordNotUnique はデータベースのユニークインデックスから発生し、2 つのジョブが別々のワーカーで同時に動いても有効です。モデル側の validates :delivery_id, uniqueness: true だけではそうはなりません。SubmissionMailer は、ジョブがフィールドを使って行う処理の代わりです。
IkiformSignature モジュールを使ってください。Ruby 3.0 以降で動きます。動作を確認する
- Minitest のファイルを実行します。4 件実行され、失敗はありません。
- 公開された
https:の URL にデプロイし、ダッシュボードの Webhooks に追加します。 - credentials か
IKIFORM_WEBHOOK_SECRETにシークレットを設定し、アプリサーバーを再起動します。 - Send test を選びます。配信ログには
204が表示され、Rails のログにはリクエストが CSRF の警告なしにIkiformWebhooksController#createに届いたことが表示されます。 - シークレットを 1 文字変えて、もう一度テストを送ります。ログには理由付きで
400が表示されます。
ハマりどころ
| 問題 | 原因と対処 |
|---|---|
Sinatra で missing_headers | コードが request.env["webhook-id"] を読んでいます。Rack のキーは HTTP_WEBHOOK_ID です |
Rails で ActionController::InvalidAuthenticityToken | コントローラーが CSRF 保護を継承しています。ActionController::API か skip_forgery_protection を使ってください |
Ruby のアップグレード後に cannot load such file -- base64 | Ruby 3.4 で base64 がデフォルトの gem から外れました。Gemfile に追加するか、上のように pack("m0") を使ってください |
NoMethodError: undefined method 'secure_compare' | Ruby が 3.0 より古いです。アップグレードするか、長さが異なると false を返す Rack::Utils.secure_compare を使ってください |
| Rails でだけ署名の検証が失敗する | コントローラーより前に何かがボディを解析したか、params が再エンコードされています。request.raw_post を検証してください |
よくある質問
request.raw_post を使います。アクションが実行される時点で Rails は JSON のボディを params に解析していますが、raw_post には送られたままのバイト列が残っています。
Rack の環境から読みます。名前は大文字にしてダッシュをアンダースコアに変え、先頭に HTTP_ を付けます。webhook-id なら request.env["HTTP_WEBHOOK_ID"] です。
はい、standardwebhooks です。StandardWebhooks::Webhook.new(secret).verify(body, headers) は whsec_ シークレットと小文字のヘッダーキーを持つハッシュを受け取り、チェックに失敗すると WebhookVerificationError を発生させます。
コントローラーが ActionController::Base を継承している場合だけです。ActionController::API のコントローラーには CSRF チェックがありません。どちらにしても、このアクションを守るのは署名です。
Standard Webhooks の署名は 16 進数ではなく base64 です。OpenSSL::HMAC.digest で生のバイト列を取得して [digest].pack("m0") でエンコードし、ヘッダー内の v1, のあとの値と比較してください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com