Verifica las firmas de webhook

Verifica las firmas de webhook6 min de lectura

Verifica firmas de webhook en Ruby y Rails

Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.

Preet SutharFundador, Ikiform

Lee el cuerpo sin procesar (request.body.read en Sinatra, request.raw_post en Rails) y las tres cabeceras webhook-*. Calcula OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") con el secreto whsec_ decodificado de base64 como clave, codifícalo en base64 y compáralo con cada firma v1 usando OpenSSL.secure_compare. En Rails, la gema standardwebhooks hace toda la comprobación en Webhook#verify.

Sinatra tiene un verificador sin dependencias con una suite de Minitest; Rails tiene la gema standardwebhooks en un controlador de API y un job que sabe lidiar con las repeticiones. Cualquiera de los dos verificadores funciona en cualquiera de los dos frameworks, y también en Hanami o Roda, porque todos se apoyan en Rack.

Qué cambia en Ruby

Rack convierte cada cabecera de la petición en una clave del entorno: webhook-id pasa a ser HTTP_WEBHOOK_ID. Rails lo oculta tras request.headers["webhook-id"], que traduce el nombre por ti. Sinatra no, así que request.env["webhook-id"] siempre es nil, y un verificador que recibe cabeceras nil falla con missing_headers aunque Ikiform las haya enviado. Rails tiene su propia versión de la trampa del cuerpo: analiza un cuerpo JSON en params por el content type application/json, y wrap_parameters puede copiar las claves bajo el nombre del controlador. Ninguna de las dos cosas cambia request.raw_post, que es el único valor que hay que verificar.

Dónde acaba una cabecera de webhook de Ikiform en Sinatra y en RailsSinatraRailswebhook-id:dlv_...en la redenv["HTTP_WEBHOOK_ID"]el nombre enRackrequest.body.readcuerpo sinprocesarverify! yluegoJSON.parsewebhook-id:dlv_...en la redrequest.headers["webhook-id"]traducido portirequest.raw_postcuerpo sinprocesarWebhook#verifyDónde acaba una cabecera de webhook de Ikiform en Sinatra y en RailsSinatraRailswebhook-id: dlv_...en la redenv["HTTP_WEBHOOK_ID"]el nombre en Rackrequest.body.readcuerpo sin procesarverify! y luegoJSON.parsewebhook-id: dlv_...en la redrequest.headers["webhook-id"]traducido por tirequest.raw_postcuerpo sin procesarWebhook#verify
  1. Ikiform envía una cabecera webhook-id en minúsculas.
  2. En Sinatra, Rack la guarda como HTTP_WEBHOOK_ID en el entorno de la petición, así que léela de ahí.
  3. request.body.read de Sinatra devuelve el cuerpo sin procesar, que se verifica antes de JSON.parse.
  4. En Rails, request.headers traduce por ti el nombre de la cabecera a la clave de Rack, y request.raw_post devuelve el cuerpo sin procesar para que lo verifique la gema.
La misma cabecera, dos formas de escribirla. El verificador solo necesita los tres valores en un hash con claves en minúsculas.

Sinatra con 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") y pack("m0") son base64 estricto sin la librería base64, que dejó de ser una gema por defecto en Ruby 3.4 y, si no, hay que añadirla al Gemfile.
  • OpenSSL.secure_compare es de tiempo constante y necesita Ruby 3.0 o posterior. Calcula primero el hash de ambos valores, así que las longitudes distintas no son un problema.
  • Construir la cadena firmada a partir de un id ASCII y un cuerpo binario es seguro: Ruby conserva los bytes del cuerpo tal cual.
  • Sinatra no analiza los cuerpos JSON, así que request.body.read es la primera lectura. Evita middleware como Rack::JSONBodyParser en esta ruta.
  • halt 400 detiene la ruta con el motivo como cuerpo, que luego aparece en el registro de entregas de Ikiform, así que un secreto incorrecto se lee ahí como no_matching_signature.
  • Rack 3 ya no garantiza que el cuerpo de la petición se pueda rebobinar, así que léelo una vez en una cadena y usa esa cadena tanto para verificar como para analizar.

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

Ejecútalo con ruby ikiform_signature_test.rb. Usa el vector de prueba con un now fijo. JSON.pretty_generate es la forma en que un cuerpo se vuelve a serializar en Ruby, así que ese caso demuestra que la comprobación lo detectaría. En un proyecto que use Bundler, añade minitest al grupo de test del Gemfile: viene con Ruby como gema incluida, y Bundler solo carga las gemas que lista el Gemfile.

Rails con la gema standardwebhooks

Añade gem "standardwebhooks" al Gemfile y ejecuta bundle install. Guarda el secreto con bin/rails credentials:edit en ikiform.webhook_secret, o léelo de 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 no tiene protección CSRF. Si el controlador hereda en cambio de ApplicationController, añade skip_forgery_protection, o Rails rechazará el envío.
  • request.raw_post es el cuerpo tal como se envió, guardado en caché tras la primera lectura, así que llamarlo dos veces es seguro. No verifiques params.to_json: Rails ya ha analizado y fusionado el JSON en params, y los bytes son distintos.
  • La gema lee las cabeceras de un hash con claves exactas en minúsculas, así que constrúyelo como se muestra. Comprueba la marca de tiempo y cada firma v1, y devuelve el JSON analizado.
  • perform_later pone el trabajo en cola y head :no_content responde 204 al instante, holgadamente dentro de los 15 segundos de Ikiform. El job puede saltarse los ids de entrega que ya haya visto.

Saltar las repeticiones en el job

Responder 204 al instante y hacer el trabajo en un job es la estructura correcta, y significa que el job tiene que soportar recibir la misma entrega dos veces: cuando Ikiform no recibe tu respuesta en 15 segundos, reintenta con el mismo webhook-id, y tu controlador pone en cola un segundo job. Un índice único hace que el segundo no haga nada:

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

La inserción y el trabajo comparten una transacción, así que si el email falla, el id se revierte y el reintento propio del job vuelve a ejecutarse. RecordNotUnique viene del índice único de la base de datos, que se mantiene incluso cuando dos jobs se ejecutan a la vez en workers distintos; un validates :delivery_id, uniqueness: true a nivel de modelo por sí solo no lo haría. SubmissionMailer representa lo que sea que el job haga con los campos.

La versión actual de la gema necesita Ruby 3.3 o posterior. En un Ruby anterior, usa en el controlador el módulo IkiformSignature de arriba; funciona en Ruby 3.0 y posteriores.

Comprueba que funciona

  1. Ejecuta el archivo de Minitest. Cuatro ejecuciones, ningún fallo.
  2. Despliega detrás de una URL https: pública y añádela en Webhooks en el panel.
  3. Pon el secreto en las credentials o en IKIFORM_WEBHOOK_SECRET y reinicia el servidor de la app.
  4. Elige Send test. El registro de entregas muestra 204, y el log de Rails muestra la petición llegando a IkiformWebhooksController#create sin ningún aviso de CSRF.
  5. Cambia un carácter del secreto y envía otra prueba. El registro muestra 400 con el motivo.

Errores comunes

ProblemaCausa y solución
missing_headers en SinatraEl código leyó request.env["webhook-id"]. La clave de Rack es HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken en RailsEl controlador hereda la protección CSRF. Usa ActionController::API o skip_forgery_protection
cannot load such file -- base64 tras actualizar RubyRuby 3.4 sacó base64 de las gemas por defecto. Añádela al Gemfile, o usa pack("m0") como arriba
NoMethodError: undefined method 'secure_compare'Ruby anterior a 3.0. Actualiza, o usa Rack::Utils.secure_compare, que devuelve false si las longitudes difieren
La firma falla solo en RailsAlgo analizó el cuerpo antes del controlador, o params se volvió a codificar. Verifica request.raw_post

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.