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.
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.
- Ikiform envía una cabecera webhook-id en minúsculas.
- En Sinatra, Rack la guarda como HTTP_WEBHOOK_ID en el entorno de la petición, así que léela de ahí.
- request.body.read de Sinatra devuelve el cuerpo sin procesar, que se verifica antes de JSON.parse.
- 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.
Sinatra con 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")ypack("m0")son base64 estricto sin la libreríabase64, que dejó de ser una gema por defecto en Ruby 3.4 y, si no, hay que añadirla al Gemfile.OpenSSL.secure_comparees 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.reades la primera lectura. Evita middleware comoRack::JSONBodyParseren esta ruta. halt 400detiene 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í comono_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
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
endEjecú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.
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::APIno tiene protección CSRF. Si el controlador hereda en cambio deApplicationController, añadeskip_forgery_protection, o Rails rechazará el envío.request.raw_postes el cuerpo tal como se envió, guardado en caché tras la primera lectura, así que llamarlo dos veces es seguro. No verifiquesparams.to_json: Rails ya ha analizado y fusionado el JSON enparams, 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_laterpone el trabajo en cola yhead :no_contentresponde204al 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:
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
endLa 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.
IkiformSignature de arriba; funciona en Ruby 3.0 y posteriores.Comprueba que funciona
- Ejecuta el archivo de Minitest. Cuatro ejecuciones, ningún fallo.
- Despliega detrás de una URL
https:pública y añádela en Webhooks en el panel. - Pon el secreto en las credentials o en
IKIFORM_WEBHOOK_SECRETy reinicia el servidor de la app. - Elige Send test. El registro de entregas muestra
204, y el log de Rails muestra la petición llegando aIkiformWebhooksController#createsin ningún aviso de CSRF. - Cambia un carácter del secreto y envía otra prueba. El registro muestra
400con el motivo.
Errores comunes
| Problema | Causa y solución |
|---|---|
missing_headers en Sinatra | El código leyó request.env["webhook-id"]. La clave de Rack es HTTP_WEBHOOK_ID |
ActionController::InvalidAuthenticityToken en Rails | El controlador hereda la protección CSRF. Usa ActionController::API o skip_forgery_protection |
cannot load such file -- base64 tras actualizar Ruby | Ruby 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 Rails | Algo analizó el cuerpo antes del controlador, o params se volvió a codificar. Verifica request.raw_post |
Preguntas
Usa request.raw_post. Cuando se ejecuta tu acción, Rails ya ha analizado un cuerpo JSON en params, pero raw_post sigue conteniendo los bytes exactamente como se enviaron.
Léela del entorno de Rack, en mayúsculas, con los guiones cambiados por guiones bajos y HTTP_ delante: webhook-id es request.env["HTTP_WEBHOOK_ID"].
Sí, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) recibe el secreto whsec_ y un hash con claves de cabecera en minúsculas, y lanza WebhookVerificationError cuando falla una comprobación.
Solo si el controlador hereda de ActionController::Base. Los controladores ActionController::API no tienen comprobación CSRF. En cualquier caso, la firma es la protección de esta acción.
Las firmas de Standard Webhooks son base64, no hex. Usa OpenSSL::HMAC.digest para obtener bytes sin procesar y codifícalos con [digest].pack("m0"); luego compáralos con el valor que va después de v1, en la cabecera.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.