Vérifier les signatures de webhook

Vérifier les signatures de webhook6 min de lecture

Vérifier les signatures de webhook en Ruby et Rails

Vérifiez les signatures des webhooks Ikiform dans Sinatra avec OpenSSL et un test Minitest, et dans Rails avec la gem standardwebhooks et request.raw_post.

Preet SutharFondateur, Ikiform

Lisez le corps brut (request.body.read dans Sinatra, request.raw_post dans Rails) et les trois en-têtes webhook-*. Calculez OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") avec le secret whsec_ décodé en base64 comme clé, encodez-le en base64 et comparez-le à chaque signature v1 avec OpenSSL.secure_compare. Dans Rails, la gem standardwebhooks fait toute la vérification dans Webhook#verify.

Sinatra reçoit un vérificateur sans dépendance avec une suite Minitest ; Rails reçoit la gem standardwebhooks dans un contrôleur d'API et un job qui gère les répétitions. Chaque vérificateur fonctionne dans l'un ou l'autre framework, ainsi que dans Hanami ou Roda, puisqu'ils reposent tous sur Rack.

Ce qui diffère en Ruby

Rack transforme chaque en-tête de requête en clé d'environnement : webhook-id devient HTTP_WEBHOOK_ID. Rails le masque derrière request.headers["webhook-id"], qui fait la correspondance pour vous. Sinatra ne le fait pas, donc request.env["webhook-id"] vaut toujours nil, et un vérificateur qui reçoit des en-têtes nil échoue avec missing_headers alors qu'Ikiform les a bien envoyés. Rails a sa propre version du piège du corps : il analyse un corps JSON dans params à cause du type de contenu application/json, et wrap_parameters peut copier les clés sous le nom du contrôleur. Aucun des deux ne modifie request.raw_post, la seule valeur à vérifier.

Où se retrouve un en-tête de webhook Ikiform dans Sinatra et dans RailsSinatraRailswebhook-id:dlv_...sur le réseauenv["HTTP_WEBHOOK_ID"]le nom que luidonne Rackrequest.body.readcorps brutverify!,puisJSON.parsewebhook-id:dlv_...sur le réseaurequest.headers["webhook-id"]correspondanceautomatiquerequest.raw_postcorps brutWebhook#verifyOù se retrouve un en-tête de webhook Ikiform dans Sinatra et dans RailsSinatraRailswebhook-id: dlv_...sur le réseauenv["HTTP_WEBHOOK_ID"]le nom que lui donne Rackrequest.body.readcorps brutverify!, puis JSON.parsewebhook-id: dlv_...sur le réseaurequest.headers["webhook-id"]correspondance automatiquerequest.raw_postcorps brutWebhook#verify
  1. Ikiform envoie un en-tête webhook-id en minuscules.
  2. Dans Sinatra, Rack le stocke sous HTTP_WEBHOOK_ID dans l'environnement de la requête, donc lisez-le à cet endroit.
  3. Le request.body.read de Sinatra renvoie le corps brut, qui est vérifié avant JSON.parse.
  4. Dans Rails, request.headers fait correspondre le nom de l'en-tête à la clé Rack pour vous, et request.raw_post renvoie le corps brut que la gem vérifie.
Même en-tête, deux graphies. Le vérificateur n'a besoin que des trois valeurs dans un hash avec des clés en minuscules.

Sinatra avec 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") et pack("m0") font du base64 strict sans la bibliothèque base64, qui n'est plus une gem par défaut depuis Ruby 3.4 et devrait sinon être ajoutée au Gemfile.
  • OpenSSL.secure_compare est en temps constant et nécessite Ruby 3.0 ou plus récent. Il hache d'abord les deux valeurs, donc des longueurs différentes ne posent pas de problème.
  • Construire la chaîne signée à partir d'un identifiant ASCII et d'un corps binaire est sans risque : Ruby conserve les octets du corps tels quels.
  • Sinatra n'analyse pas les corps JSON, donc request.body.read est la première lecture. Évitez les middlewares comme Rack::JSONBodyParser sur cette route.
  • halt 400 arrête la route avec la raison comme corps, qui apparaît alors dans le journal de livraison d'Ikiform, donc un mauvais secret s'y lit comme no_matching_signature.
  • Rack 3 ne garantit plus que le corps de la requête puisse être rembobiné, donc lisez-le une fois dans une chaîne et utilisez cette chaîne pour la vérification comme pour l'analyse.

Un test 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

Lancez-le avec ruby ikiform_signature_test.rb. Il utilise le vecteur de test avec un now fixe. JSON.pretty_generate est la façon dont un corps se retrouve resérialisé en Ruby, donc ce cas montre que la vérification le détecterait. Dans un projet qui utilise Bundler, ajoutez minitest au groupe test du Gemfile : il est livré avec Ruby comme bundled gem, et Bundler ne charge que les gems listées dans le Gemfile.

Rails avec la gem standardwebhooks

Ajoutez gem "standardwebhooks" au Gemfile et lancez bundle install. Stockez le secret avec bin/rails credentials:edit sous ikiform.webhook_secret, ou lisez-le depuis 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 n'a pas de protection CSRF. Si le contrôleur hérite plutôt d'ApplicationController, ajoutez skip_forgery_protection, sinon Rails refuse l'envoi.
  • request.raw_post est le corps tel qu'envoyé, mis en cache après la première lecture, donc l'appeler deux fois est sans risque. Ne vérifiez pas params.to_json : Rails a déjà analysé et fusionné le JSON dans params, et les octets diffèrent.
  • La gem lit les en-têtes depuis un hash avec des clés exactes en minuscules, donc construisez-le comme indiqué. Elle vérifie le timestamp et chaque signature v1, et renvoie le JSON analysé.
  • perform_later met le travail en file d'attente et head :no_content répond 204 immédiatement, bien avant les 15 secondes d'Ikiform. Le job peut ignorer les identifiants de livraison déjà vus.

Ignorer les répétitions dans le job

Répondre 204 immédiatement et faire le travail dans un job est la bonne structure, et cela signifie que le job doit gérer deux fois la même livraison : quand Ikiform ne reçoit pas votre réponse en 15 secondes, il réessaie avec le même webhook-id, et votre contrôleur met un second job en file d'attente. Un index unique rend le second sans effet :

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

L'insert et le travail partagent une transaction, donc si l'e-mail échoue, l'identifiant est annulé et la nouvelle tentative propre au job s'exécute à nouveau. RecordNotUnique vient de l'index unique de la base de données, qui tient même quand deux jobs s'exécutent au même moment sur des workers différents ; un validates :delivery_id, uniqueness: true au niveau du modèle ne suffirait pas à lui seul. SubmissionMailer représente ce que le job fait des champs, quel que soit ce traitement.

La version actuelle de la gem nécessite Ruby 3.3 ou plus récent. Sur un Ruby plus ancien, utilisez plutôt le module IkiformSignature ci-dessus dans le contrôleur ; il fonctionne sur Ruby 3.0 et plus récent.

Vérifiez que tout fonctionne

  1. Lancez le fichier Minitest. Quatre exécutions, aucun échec.
  2. Déployez derrière une URL https: publique et ajoutez-la sous Webhooks dans le tableau de bord.
  3. Définissez le secret dans les credentials ou dans IKIFORM_WEBHOOK_SECRET et redémarrez le serveur d'application.
  4. Choisissez Send test. Le journal de livraison affiche 204, et le journal Rails montre la requête qui atteint IkiformWebhooksController#create sans avertissement CSRF.
  5. Modifiez un caractère du secret et envoyez un autre test. Le journal affiche 400 avec la raison.

Pièges

ProblèmeCause et solution
missing_headers dans SinatraLe code a lu request.env["webhook-id"]. La clé Rack est HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken dans RailsLe contrôleur hérite de la protection CSRF. Utilisez ActionController::API ou skip_forgery_protection
cannot load such file -- base64 après une mise à jour de RubyRuby 3.4 a sorti base64 des gems par défaut. Ajoutez-la au Gemfile, ou utilisez pack("m0") comme ci-dessus
NoMethodError: undefined method 'secure_compare'Ruby antérieur à 3.0. Mettez à jour, ou utilisez Rack::Utils.secure_compare, qui renvoie false pour des longueurs différentes
La signature n'échoue que sous RailsQuelque chose a analysé le corps avant le contrôleur, ou params a été réencodé. Vérifiez request.raw_post

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.