Webhook-Signaturen prüfen5 Min. Lesezeit
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.
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.
- Ikiform sendet einen kleingeschriebenen Header webhook-id.
- In Sinatra speichert Rack ihn als HTTP_WEBHOOK_ID in der Request-Umgebung, also lies ihn dort.
- request.body.read in Sinatra gibt den rohen Body zurück, der vor JSON.parse verifiziert wird.
- 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.
Sinatra mit 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")undpack("m0")sind striktes base64 ohne die Bibliothekbase64, die in Ruby 3.4 kein Default-Gem mehr ist und sonst zum Gemfile hinzugefügt werden muss.OpenSSL.secure_comparearbeitet 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.readder erste Lesevorgang. Vermeide auf dieser Route Middleware wieRack::JSONBodyParser. halt 400stoppt die Route mit dem Grund als Body, der dann im Zustellungsprotokoll von Ikiform erscheint, sodass ein falsches Secret dort alsno_matching_signaturezu 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
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
endFühre ihn mit ruby ikiform_signature_test.rb aus. Er nutzt den Testvektor 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.
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::APIhat keinen CSRF-Schutz. Erbt der Controller stattdessen vonApplicationController, fügeskip_forgery_protectionhinzu, sonst lehnt Rails den Post ab.request.raw_postist der Body wie gesendet, nach dem ersten Lesen gecacht, also ist ein zweiter Aufruf sicher. Verifiziere nichtparams.to_json: Rails hat das JSON bereits geparst und inparamsgemischt, 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_laterlegt die Arbeit in die Queue, undhead :no_contentantwortet sofort mit204, 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:
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
endInsert 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.
IkiformSignature von oben im Controller; es läuft ab Ruby 3.0.Prüfen, ob es funktioniert
- Führe die Minitest-Datei aus. Vier Durchläufe, keine Fehler.
- Deploye hinter einer öffentlichen
https:-URL und füge sie im Dashboard unter Webhooks hinzu. - Setze das Secret in den Credentials oder in
IKIFORM_WEBHOOK_SECRETund starte den App-Server neu. - Wähle Send test. Das Zustellungsprotokoll zeigt
204, und das Rails-Log zeigt, dass der RequestIkiformWebhooksController#createohne CSRF-Warnung erreicht. - Ändere ein Zeichen des Secrets und sende einen weiteren Test. Das Protokoll zeigt
400mit 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
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.
Lies ihn aus der Rack-Umgebung, in Großbuchstaben, mit Unterstrichen statt Bindestrichen und HTTP_ davor: webhook-id ist request.env["HTTP_WEBHOOK_ID"].
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.