Webhook の署名を検証する

Webhook の署名を検証する2 分で読めます

Ruby と Rails で Webhook 署名を検証する

Ikiform の Webhook 署名を、Sinatra では OpenSSL と Minitest のテストで、Rails では standardwebhooks gem と request.raw_post で検証します。

Preet SutharIkiform 創業者

生のボディ(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 ヘッダーが Sinatra と Rails でどこに入るかSinatraRailswebhook-id:dlv_...通信上env["HTTP_WEBHOOK_ID"]Rack での名前request.body.read生のボディverify! のあとJSON.parsewebhook-id:dlv_...通信上request.headers["webhook-id"]自動で対応づけrequest.raw_post生のボディWebhook#verifyIkiform の Webhook ヘッダーが Sinatra と Rails でどこに入るかSinatraRailswebhook-id: dlv_...通信上env["HTTP_WEBHOOK_ID"]Rack での名前request.body.read生のボディverify! のあと JSON.parsewebhook-id: dlv_...通信上request.headers["webhook-id"]自動で対応づけrequest.raw_post生のボディWebhook#verify
  1. Ikiform は小文字の webhook-id ヘッダーを送ります。
  2. Sinatra では、Rack がそれをリクエスト環境に HTTP_WEBHOOK_ID として保存するので、そこから読みます。
  3. Sinatra の request.body.read は生のボディを返し、JSON.parse の前にそれを検証します。
  4. Rails では、request.headers がヘッダー名を Rack のキーに対応づけてくれ、request.raw_post が gem で検証する生のボディを返します。
同じヘッダーに 2 つの表記があります。検証関数に必要なのは、小文字のキーを持つハッシュに入った 3 つの値だけです。

Sinatra と OpenSSL

ikiform_signature.rb
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
end
app.rb
require "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
end
  • unpack1("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 のテスト

ikiform_signature_test.rb
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
end

ruby 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 から読みます。

app/controllers/ikiform_webhooks_controller.rb
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
end
config/routes.rb
Rails.application.routes.draw do
  post "webhooks/ikiform", to: "ikiform_webhooks#create"
end
  • ActionController::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 つ目は何もしません:

db/migrate/20261025000000_create_webhook_deliveries.rb
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
end
app/jobs/ikiform_submission_job.rb
class 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 は、ジョブがフィールドを使って行う処理の代わりです。

gem の現在のリリースは Ruby 3.3 以降が必要です。古い Ruby では、代わりにコントローラーで上の IkiformSignature モジュールを使ってください。Ruby 3.0 以降で動きます。

動作を確認する

  1. Minitest のファイルを実行します。4 件実行され、失敗はありません。
  2. 公開された https: の URL にデプロイし、ダッシュボードの Webhooks に追加します。
  3. credentials か IKIFORM_WEBHOOK_SECRET にシークレットを設定し、アプリサーバーを再起動します。
  4. Send test を選びます。配信ログには 204 が表示され、Rails のログにはリクエストが CSRF の警告なしに IkiformWebhooksController#create に届いたことが表示されます。
  5. シークレットを 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 -- base64Ruby 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 を検証してください

よくある質問

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