# 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.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/webhook-signature-verification/ruby

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.

Schéma : Où se retrouve un en-tête de webhook Ikiform dans Sinatra et dans Rails

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

```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")` 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`:

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

Lancez-le avec `ruby ikiform_signature_test.rb`. Il utilise le [vecteur de test](https://ikiform.com/fr/guides/webhook-signature-verification.md#test-vector) 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`:

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

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

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

### Comment obtenir le corps brut de la requête dans Rails ?

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.

### Comment lire un en-tête personnalisé dans Sinatra ?

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"].

### Existe-t-il une gem Standard Webhooks pour Ruby ?

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.

### Dois-je désactiver la protection CSRF pour un webhook Rails ?

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.

### Pourquoi OpenSSL::HMAC.hexdigest ne correspond-il pas à la signature ?

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.

## Autres guides

- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [PHP](https://ikiform.com/fr/guides/webhook-signature-verification/php.md): Vérifiez les signatures des webhooks Ikiform en PHP simple avec hash_hmac et hash_equals, et dans Laravel avec la bibliothèque Standard Webhooks et une exception CSRF.
- [Python](https://ikiform.com/fr/guides/webhook-signature-verification/python.md): Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.
- [Go net/http](https://ikiform.com/fr/guides/webhook-signature-verification/go.md): Vérifiez les signatures des webhooks Ikiform en Go avec net/http : lisez le corps une seule fois, vérifiez-le avec crypto/hmac, testez-le, ou utilisez la bibliothèque Standard Webhooks.
- [Node.js](https://ikiform.com/fr/guides/webhook-signature-verification/nodejs.md): Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.
