Webhook-Signaturen prüfen

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.

Preet SutharGründer, Ikiform

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.

Wo ein Ikiform-Webhook-Header in Sinatra und in Rails landetSinatraRailswebhook-id:dlv_...in der Leitungenv["HTTP_WEBHOOK_ID"]Racks Namedafürrequest.body.readroher Bodyverify!,dannJSON.parsewebhook-id:dlv_...in der Leitungrequest.headers["webhook-id"]für dichübersetztrequest.raw_postroher BodyWebhook#verifyWo ein Ikiform-Webhook-Header in Sinatra und in Rails landetSinatraRailswebhook-id: dlv_...in der Leitungenv["HTTP_WEBHOOK_ID"]Racks Name dafürrequest.body.readroher Bodyverify!, dann JSON.parsewebhook-id: dlv_...in der Leitungrequest.headers["webhook-id"]für dich übersetztrequest.raw_postroher BodyWebhook#verify
  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
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") 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
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 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
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 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
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

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

ProblemUrsache und Lösung
missing_headers in SinatraDer Code hat request.env["webhook-id"] gelesen. Der Key von Rack ist HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken in RailsDer Controller erbt CSRF-Schutz. Nutze ActionController::API oder skip_forgery_protection
cannot load such file -- base64 nach einem Ruby-UpgradeRuby 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 RailsEtwas hat den Body vor dem Controller geparst, oder params wurde neu kodiert. Verifiziere request.raw_post

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.