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.
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.
- Ikiform envoie un en-tête webhook-id en minuscules.
- Dans Sinatra, Rack le stocke sous HTTP_WEBHOOK_ID dans l'environnement de la requête, donc lisez-le à cet endroit.
- Le request.body.read de Sinatra renvoie le corps brut, qui est vérifié avant JSON.parse.
- 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.
Sinatra avec 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")etpack("m0")font du base64 strict sans la bibliothèquebase64, qui n'est plus une gem par défaut depuis Ruby 3.4 et devrait sinon être ajoutée au Gemfile.OpenSSL.secure_compareest 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.readest la première lecture. Évitez les middlewares commeRack::JSONBodyParsersur cette route. halt 400arrê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 commeno_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
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
endLancez-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.
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::APIn'a pas de protection CSRF. Si le contrôleur hérite plutôt d'ApplicationController, ajoutezskip_forgery_protection, sinon Rails refuse l'envoi.request.raw_postest 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 pasparams.to_json: Rails a déjà analysé et fusionné le JSON dansparams, 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_latermet le travail en file d'attente ethead :no_contentrépond204immé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 :
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
endL'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.
IkiformSignature ci-dessus dans le contrôleur ; il fonctionne sur Ruby 3.0 et plus récent.Vérifiez que tout fonctionne
- Lancez le fichier Minitest. Quatre exécutions, aucun échec.
- Déployez derrière une URL
https:publique et ajoutez-la sous Webhooks dans le tableau de bord. - Définissez le secret dans les credentials ou dans
IKIFORM_WEBHOOK_SECRETet redémarrez le serveur d'application. - Choisissez Send test. Le journal de livraison affiche
204, et le journal Rails montre la requête qui atteintIkiformWebhooksController#createsans avertissement CSRF. - Modifiez un caractère du secret et envoyez un autre test. Le journal affiche
400avec la raison.
Pièges
| Problème | Cause et solution |
|---|---|
missing_headers dans Sinatra | Le code a lu request.env["webhook-id"]. La clé Rack est HTTP_WEBHOOK_ID |
ActionController::InvalidAuthenticityToken dans Rails | Le 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 Ruby | Ruby 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 Rails | Quelque chose a analysé le corps avant le contrôleur, ou params a été réencodé. Vérifiez request.raw_post |
Questions
Utilisez request.raw_post. Au moment où votre action s'exécute, Rails a analysé un corps JSON dans params, mais raw_post contient toujours les octets exactement tels qu'envoyés.
Lisez-le depuis l'environnement Rack, en majuscules, avec les tirets transformés en underscores et précédé de HTTP_ : webhook-id correspond à request.env["HTTP_WEBHOOK_ID"].
Oui, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) prend le secret whsec_ et un hash avec des clés d'en-têtes en minuscules, et lève WebhookVerificationError quand une vérification échoue.
Seulement si le contrôleur hérite d'ActionController::Base. Les contrôleurs ActionController::API n'ont pas de vérification CSRF. Dans les deux cas, c'est la signature qui protège cette action.
Les signatures Standard Webhooks sont en base64, pas en hexadécimal. Utilisez OpenSSL::HMAC.digest pour obtenir des octets bruts et encodez-les avec [digest].pack("m0"), puis comparez avec la valeur qui suit v1, dans l'en-tête.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.