# Webhook-Signaturen in Ruby und Rails verifizieren

> Verifiziere Ikiform-Webhook-Signaturen in Sinatra mit OpenSSL und einem Minitest-Test und in Rails mit dem Gem standardwebhooks und request.raw_post.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/webhook-signature-verification/ruby

Lies den rohen Body (`request.body.read` in Sinatra, `request.raw_post` in Rails) und die drei `webhook-*`-Header. Berechne `OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")` mit dem base64-dekodierten `whsec_`-Secret als Key, kodiere es in base64 und vergleiche es mit `OpenSSL.secure_compare` mit jeder `v1`-Signatur. In Rails erledigt das Gem `standardwebhooks` die ganze Prüfung in `Webhook#verify`.

Sinatra bekommt einen Verifizierer ohne Abhängigkeiten mit einer Minitest-Suite; Rails bekommt das Gem `standardwebhooks` in einem API-Controller und einen Job, der mit Wiederholungen umgehen kann. Jeder Verifizierer funktioniert in jedem der beiden Frameworks und auch in Hanami oder Roda, da alle auf Rack aufsetzen.

## Was in Ruby anders ist

Rack macht aus jedem Request-Header einen Umgebungs-Key: `webhook-id` wird zu `HTTP_WEBHOOK_ID`. Rails versteckt das hinter `request.headers["webhook-id"]`, das den Namen für dich übersetzt. Sinatra tut das nicht, also ist `request.env["webhook-id"]` immer `nil`, und ein Verifizierer, der `nil`-Header bekommt, scheitert mit `missing_headers`, obwohl Ikiform sie gesendet hat. Rails hat seine eigene Version der Body-Falle: Es parst einen JSON-Body wegen des Content-Types `application/json` in `params`, und `wrap_parameters` kopiert die Keys eventuell unter den Namen des Controllers. Keins davon ändert `request.raw_post`, den einzigen Wert, der verifiziert werden sollte.

Diagramm: Wo ein Ikiform-Webhook-Header in Sinatra und in Rails landet

1. Ikiform sendet einen kleingeschriebenen Header webhook-id.
2. In Sinatra speichert Rack ihn als HTTP_WEBHOOK_ID in der Request-Umgebung, also lies ihn dort.
3. request.body.read in Sinatra gibt den rohen Body zurück, der vor JSON.parse verifiziert wird.
4. In Rails übersetzt request.headers den Header-Namen für dich in den Rack-Key, und request.raw_post gibt den rohen Body zurück, den das Gem verifiziert.

Derselbe Header, zwei Schreibweisen. Der Verifizierer braucht nur die drei Werte in einem Hash mit kleingeschriebenen Keys.

## Sinatra mit 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")` und `pack("m0")` sind striktes base64 ohne die Bibliothek `base64`, die in Ruby 3.4 kein Default-Gem mehr ist und sonst zum Gemfile hinzugefügt werden muss.
- `OpenSSL.secure_compare` arbeitet in konstanter Zeit und braucht Ruby 3.0 oder neuer. Es hasht beide Werte zuerst, also sind unterschiedliche Längen kein Problem.
- Den signierten String aus einer ASCII-ID und einem binären Body zu bauen ist sicher: Ruby lässt die Bytes des Bodys, wie sie sind.
- Sinatra parst keine JSON-Bodys, also ist `request.body.read` der erste Lesevorgang. Vermeide auf dieser Route Middleware wie `Rack::JSONBodyParser`.
- `halt 400` stoppt die Route mit dem Grund als Body, der dann im Zustellungsprotokoll von Ikiform erscheint, sodass ein falsches Secret dort als `no_matching_signature` zu lesen ist.
- Rack 3 verspricht nicht mehr, dass sich der Request-Body zurückspulen lässt, also lies ihn einmal in einen String und nutze diesen String sowohl zum Verifizieren als auch zum Parsen.

## Ein Minitest-Test

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

Führe ihn mit `ruby ikiform_signature_test.rb` aus. Er nutzt den [Testvektor](https://ikiform.com/de/guides/webhook-signature-verification.md#test-vector) mit einem festen `now`. `JSON.pretty_generate` ist der Weg, auf dem ein Body in Ruby neu serialisiert wird, also zeigt dieser Fall, dass die Prüfung ihn erwischen würde. Füge in einem Projekt mit Bundler `minitest` zur Testgruppe des Gemfiles hinzu: Es wird mit Ruby als Bundled Gem ausgeliefert, und Bundler lädt nur Gems, die im Gemfile stehen.

## Rails mit dem Gem standardwebhooks

Füge `gem "standardwebhooks"` zum Gemfile hinzu und führe `bundle install` aus. Speichere das Secret mit `bin/rails credentials:edit` unter `ikiform.webhook_secret` oder lies es aus `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` hat keinen CSRF-Schutz. Erbt der Controller stattdessen von `ApplicationController`, füge `skip_forgery_protection` hinzu, sonst lehnt Rails den Post ab.
- `request.raw_post` ist der Body wie gesendet, nach dem ersten Lesen gecacht, also ist ein zweiter Aufruf sicher. Verifiziere nicht `params.to_json`: Rails hat das JSON bereits geparst und in `params` gemischt, und die Bytes unterscheiden sich.
- Das Gem liest die Header aus einem Hash mit exakten kleingeschriebenen Keys, also baue ihn wie gezeigt. Es prüft den Zeitstempel und jede `v1`-Signatur und gibt das geparste JSON zurück.
- `perform_later` legt die Arbeit in die Queue, und `head :no_content` antwortet sofort mit `204`, deutlich innerhalb der 15 Sekunden von Ikiform. Der Job kann Zustellungs-IDs überspringen, die er schon gesehen hat.

## Wiederholungen im Job überspringen

Sofort mit `204` zu antworten und die Arbeit in einem Job zu erledigen ist die richtige Form, und es heißt, dass der Job mit derselben Zustellung zweimal umgehen muss: Bekommt Ikiform deine Antwort nicht innerhalb von 15 Sekunden, wiederholt es mit derselben `webhook-id`, und dein Controller legt einen zweiten Job an. Ein eindeutiger Index macht den zweiten zu einem No-op:

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

Insert und Arbeit teilen sich eine Transaktion, also wird die ID zurückgerollt, wenn die Mail scheitert, und die eigene Wiederholung des Jobs läuft erneut. `RecordNotUnique` kommt vom eindeutigen Index der Datenbank, der auch hält, wenn zwei Jobs im selben Moment auf verschiedenen Workern laufen; ein `validates :delivery_id, uniqueness: true` auf Modellebene allein würde das nicht. `SubmissionMailer` steht für das, was der Job mit den Feldern macht.

> Die aktuelle Version des Gems braucht Ruby 3.3 oder neuer. Nutze auf einem älteren Ruby stattdessen das Modul `IkiformSignature` von oben im Controller; es läuft ab Ruby 3.0.

## Prüfen, ob es funktioniert

1. Führe die Minitest-Datei aus. Vier Durchläufe, keine Fehler.
2. Deploye hinter einer öffentlichen `https:`-URL und füge sie im Dashboard unter **Webhooks** hinzu.
3. Setze das Secret in den Credentials oder in `IKIFORM_WEBHOOK_SECRET` und starte den App-Server neu.
4. Wähle **Send test**. Das Zustellungsprotokoll zeigt `204`, und das Rails-Log zeigt, dass der Request `IkiformWebhooksController#create` ohne CSRF-Warnung erreicht.
5. Ändere ein Zeichen des Secrets und sende einen weiteren Test. Das Protokoll zeigt `400` mit dem Grund.

## Stolperfallen

| Problem | Ursache und Lösung |
| --- | --- |
| `missing_headers` in Sinatra | Der Code hat `request.env["webhook-id"]` gelesen. Der Key von Rack ist `HTTP_WEBHOOK_ID` |
| `ActionController::InvalidAuthenticityToken` in Rails | Der Controller erbt CSRF-Schutz. Nutze `ActionController::API` oder `skip_forgery_protection` |
| `cannot load such file -- base64` nach einem Ruby-Upgrade | Ruby 3.4 hat `base64` aus den Default-Gems genommen. Füge es zum Gemfile hinzu oder nutze wie oben `pack("m0")` |
| `NoMethodError: undefined method 'secure_compare'` | Ruby älter als 3.0. Upgrade oder nutze `Rack::Utils.secure_compare`, das bei unterschiedlichen Längen `false` zurückgibt |
| Die Signatur scheitert nur in Rails | Etwas hat den Body vor dem Controller geparst, oder `params` wurde neu kodiert. Verifiziere `request.raw_post` |

## Fragen

### Wie bekomme ich in Rails den rohen Request-Body?

Nutze request.raw_post. Wenn deine Action läuft, hat Rails einen JSON-Body schon in params geparst, aber raw_post enthält weiterhin die Bytes genau wie gesendet.

### Wie lese ich in Sinatra einen eigenen Header?

Lies ihn aus der Rack-Umgebung, in Großbuchstaben, mit Unterstrichen statt Bindestrichen und HTTP_ davor: webhook-id ist request.env["HTTP_WEBHOOK_ID"].

### Gibt es ein Standard-Webhooks-Gem für Ruby?

Ja, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) nimmt das whsec_-Secret und einen Hash mit kleingeschriebenen Header-Keys und wirft WebhookVerificationError, wenn eine Prüfung scheitert.

### Muss ich für einen Rails-Webhook den CSRF-Schutz überspringen?

Nur wenn der Controller von ActionController::Base erbt. Controller auf Basis von ActionController::API haben keine CSRF-Prüfung. Die Signatur ist in beiden Fällen der Schutz dieser Action.

### Warum passt OpenSSL::HMAC.hexdigest nicht zur Signatur?

Signaturen nach Standard Webhooks sind base64, nicht Hex. Nutze OpenSSL::HMAC.digest, um rohe Bytes zu bekommen, kodiere sie mit [digest].pack("m0") und vergleiche dann mit dem Wert nach v1, im Header.

## Weitere Anleitungen

- [Webhook-Signaturen prüfen](https://ikiform.com/de/guides/webhook-signature-verification.md): Prüfe, ob ein Formular-Webhook wirklich von Ikiform kommt: das HMAC-SHA256-Schema von Standard Webhooks, ein Testvektor, Raw Bodys, Zeitstempel und Secret-Rotation.
- [PHP](https://ikiform.com/de/guides/webhook-signature-verification/php.md): Verifiziere Ikiform-Webhook-Signaturen in reinem PHP mit hash_hmac und hash_equals und in Laravel mit der Standard-Webhooks-Bibliothek und einer CSRF-Ausnahme.
- [Python](https://ikiform.com/de/guides/webhook-signature-verification/python.md): Prüfe Webhook-Signaturen von Ikiform in Flask, FastAPI und Django, mit dem Paket standardwebhooks oder der Standardbibliothek, plus einem Test mit pytest.
- [Go net/http](https://ikiform.com/de/guides/webhook-signature-verification/go.md): Verifiziere Ikiform-Webhook-Signaturen in Go mit net/http: Lies den Body einmal, prüf ihn mit crypto/hmac, teste ihn oder nutze die Standard-Webhooks-Bibliothek.
- [Node.js](https://ikiform.com/de/guides/webhook-signature-verification/nodejs.md): Prüfe Webhook-Signaturen von Ikiform in Node.js: Raw Bodys in Express und Fastify, das SDK, das Paket standardwebhooks und eine getestete Version mit node:crypto.
