Verificar assinaturas de webhook6 min de leitura
Verificar assinaturas de webhook em Ruby e Rails
Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
Leia o corpo bruto (request.body.read no Sinatra, request.raw_post no Rails) e os três headers webhook-*. Calcule OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") com o segredo whsec_ decodificado de base64 como chave, codifique em base64 e compare com cada assinatura v1 usando OpenSSL.secure_compare. No Rails, a gem standardwebhooks faz toda a verificação em Webhook#verify.
O Sinatra ganha um verificador sem dependências com uma suíte Minitest; o Rails ganha a gem standardwebhooks num controller de API e um job que lida com repetições. Qualquer um dos verificadores funciona em qualquer um dos frameworks, e no Hanami ou no Roda também, já que todos rodam sobre o Rack.
O que muda no Ruby
O Rack transforma cada header da requisição numa chave de ambiente: webhook-id vira HTTP_WEBHOOK_ID. O Rails esconde isso atrás de request.headers["webhook-id"], que mapeia o nome para você. O Sinatra não, então request.env["webhook-id"] é sempre nil, e um verificador que recebe headers nil falha com missing_headers mesmo que o Ikiform os tenha enviado. O Rails tem a sua própria versão da armadilha do corpo: ele faz o parse de um corpo JSON em params por causa do content type application/json, e o wrap_parameters pode copiar as chaves sob o nome do controller. Nenhum dos dois muda request.raw_post, que é o único valor a verificar.
- O Ikiform envia um header webhook-id em minúsculas.
- No Sinatra, o Rack o guarda como HTTP_WEBHOOK_ID no ambiente da requisição, então leia-o de lá.
- O request.body.read do Sinatra devolve o corpo bruto, que é verificado antes do JSON.parse.
- No Rails, o request.headers mapeia o nome do header para a chave do Rack por você, e o request.raw_post devolve o corpo bruto para a gem verificar.
Sinatra com 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")epack("m0")são base64 estrito sem a bibliotecabase64, que deixou de ser uma gem padrão no Ruby 3.4 e, caso contrário, precisa ser adicionada ao Gemfile.OpenSSL.secure_compareé de tempo constante e precisa do Ruby 3.0 ou posterior. Ele calcula o hash dos dois valores antes, então tamanhos diferentes não são problema.- Montar a string assinada a partir de um id ASCII e de um corpo binário é seguro: o Ruby mantém os bytes do corpo como estão.
- O Sinatra não faz o parse de corpos JSON, então
request.body.readé a primeira leitura. Evite middlewares comoRack::JSONBodyParsernesta rota. halt 400interrompe a rota com o motivo como corpo, que então aparece no log de entregas do Ikiform, então um segredo errado aparece ali comono_matching_signature.- O Rack 3 não garante mais que o corpo da requisição possa ser rebobinado, então leia-o uma vez numa string e use essa string tanto para verificar quanto para fazer o parse.
Um teste com 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
endRode com ruby ikiform_signature_test.rb. Ele usa o vetor de teste com um now fixo. JSON.pretty_generate é como um corpo é reserializado em Ruby, então esse caso mostra que a verificação o pegaria. Num projeto que usa Bundler, adicione minitest ao grupo de teste do Gemfile: ele vem com o Ruby como bundled gem, e o Bundler só carrega as gems listadas no Gemfile.
Rails com a gem standardwebhooks
Adicione gem "standardwebhooks" ao Gemfile e rode bundle install. Guarde o segredo com bin/rails credentials:edit em ikiform.webhook_secret, ou leia-o 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::APInão tem proteção CSRF. Se o controller herdar deApplicationController, adicioneskip_forgery_protection, ou o Rails recusa o envio.request.raw_posté o corpo como foi enviado, guardado em cache depois da primeira leitura, então chamá-lo duas vezes é seguro. Não verifiqueparams.to_json: o Rails já fez o parse do JSON e o mesclou emparams, e os bytes são diferentes.- A gem lê os headers de um hash com chaves exatas em minúsculas, então monte-o como mostrado. Ela confere o timestamp e cada assinatura
v1e devolve o JSON parseado. perform_latercoloca o trabalho na fila ehead :no_contentresponde204na hora, bem dentro dos 15 segundos do Ikiform. O job pode ignorar ids de entrega que já viu.
Ignorando repetições no job
Responder 204 na hora e fazer o trabalho num job é o formato certo, e isso significa que o job precisa lidar com a mesma entrega duas vezes: quando o Ikiform não recebe a sua resposta em 15 segundos, ele tenta de novo com o mesmo webhook-id, e o seu controller enfileira um segundo job. Um índice único faz o segundo não ter efeito:
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
endA inserção e o trabalho compartilham uma transação, então, se o e-mail falhar, o id é revertido e a nova tentativa do próprio job roda de novo. RecordNotUnique vem do índice único do banco de dados, que vale mesmo quando dois jobs rodam no mesmo instante em workers diferentes; um validates :delivery_id, uniqueness: true no modelo, sozinho, não daria conta. SubmissionMailer representa o que quer que o job faça com os campos.
IkiformSignature acima; ele roda no Ruby 3.0 e posteriores.Confira se funciona
- Rode o arquivo Minitest. Quatro execuções, nenhuma falha.
- Faça o deploy atrás de uma URL
https:pública e adicione-a em Webhooks no painel. - Defina o segredo nas credentials ou em
IKIFORM_WEBHOOK_SECRETe reinicie o servidor da aplicação. - Escolha Send test. O log de entregas mostra
204, e o log do Rails mostra a requisição chegando aIkiformWebhooksController#createsem aviso de CSRF. - Mude um caractere do segredo e envie outro teste. O log mostra
400com o motivo.
Pegadinhas
| Problema | Causa e correção |
|---|---|
missing_headers no Sinatra | O código leu request.env["webhook-id"]. A chave do Rack é HTTP_WEBHOOK_ID |
ActionController::InvalidAuthenticityToken no Rails | O controller herda a proteção CSRF. Use ActionController::API ou skip_forgery_protection |
cannot load such file -- base64 depois de atualizar o Ruby | O Ruby 3.4 tirou o base64 das gems padrão. Adicione-o ao Gemfile, ou use pack("m0") como acima |
NoMethodError: undefined method 'secure_compare' | Ruby anterior ao 3.0. Atualize, ou use Rack::Utils.secure_compare, que devolve false para tamanhos diferentes |
| A assinatura só falha no Rails | Algo fez o parse do corpo antes do controller, ou params foi recodificado. Verifique request.raw_post |
Perguntas
Use request.raw_post. Quando a sua action roda, o Rails já fez o parse de um corpo JSON em params, mas o raw_post ainda guarda os bytes exatamente como foram enviados.
Leia-o do ambiente do Rack, em maiúsculas, com hífens trocados por underscores e HTTP_ na frente: webhook-id é request.env["HTTP_WEBHOOK_ID"].
Sim, a standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) recebe o segredo whsec_ e um hash com chaves de header em minúsculas, e lança WebhookVerificationError quando uma verificação falha.
Só se o controller herdar de ActionController::Base. Controllers ActionController::API não têm verificação de CSRF. De qualquer forma, a assinatura é a proteção desta action.
As assinaturas do Standard Webhooks são base64, não hex. Use OpenSSL::HMAC.digest para obter bytes brutos e codifique-os com [digest].pack("m0"), depois compare com o valor que vem depois de v1, no header.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.