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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/webhook-signature-verification/ruby

生のボディ（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 でどこに入るか

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`:

```text
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`:

```text
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`:

```text
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` で実行します。[テストベクター](https://ikiform.com/ja/guides/webhook-signature-verification.md#test-vector)を固定した `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`:

```text
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`:

```text
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`:

```text
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`:

```text
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 -- 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` を検証してください |

## よくある質問

### Rails で生のリクエストボディを取得するには？

request.raw_post を使います。アクションが実行される時点で Rails は JSON のボディを params に解析していますが、raw_post には送られたままのバイト列が残っています。

### Sinatra でカスタムヘッダーを読むには？

Rack の環境から読みます。名前は大文字にしてダッシュをアンダースコアに変え、先頭に HTTP_ を付けます。webhook-id なら request.env["HTTP_WEBHOOK_ID"] です。

### Ruby 用の Standard Webhooks の gem はありますか？

はい、standardwebhooks です。StandardWebhooks::Webhook.new(secret).verify(body, headers) は whsec_ シークレットと小文字のヘッダーキーを持つハッシュを受け取り、チェックに失敗すると WebhookVerificationError を発生させます。

### Rails の Webhook では CSRF 保護をスキップする必要がありますか？

コントローラーが ActionController::Base を継承している場合だけです。ActionController::API のコントローラーには CSRF チェックがありません。どちらにしても、このアクションを守るのは署名です。

### OpenSSL::HMAC.hexdigest が署名と一致しないのはなぜですか？

Standard Webhooks の署名は 16 進数ではなく base64 です。OpenSSL::HMAC.digest で生のバイト列を取得して [digest].pack("m0") でエンコードし、ヘッダー内の v1, のあとの値と比較してください。

## その他のガイド

- [Webhook の署名を検証する](https://ikiform.com/ja/guides/webhook-signature-verification.md): フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。
- [PHP](https://ikiform.com/ja/guides/webhook-signature-verification/php.md): Ikiform の Webhook 署名を、素の PHP では hash_hmac と hash_equals で、Laravel では Standard Webhooks ライブラリと CSRF の除外で検証します。
- [Python](https://ikiform.com/ja/guides/webhook-signature-verification/python.md): Flask、FastAPI、Django で Ikiform の Webhook 署名を検証する方法。standardwebhooks パッケージか標準ライブラリを使い、pytest のテストも付けます。
- [Go の net/http](https://ikiform.com/ja/guides/webhook-signature-verification/go.md): Go の net/http で Ikiform の Webhook 署名を検証する方法。ボディを 1 回だけ読み、crypto/hmac で確認してテストするか、Standard Webhooks ライブラリを使います。
- [Node.js](https://ikiform.com/ja/guides/webhook-signature-verification/nodejs.md): Node.js で Ikiform の Webhook 署名を検証する方法。Express と Fastify での生のボディ、SDK、standardwebhooks パッケージ、テスト済みの node:crypto 版。
