Verificar assinaturas de webhook

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.

Preet SutharFundador, Ikiform

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.

Onde um header de webhook do Ikiform vai parar no Sinatra e no RailsSinatraRailswebhook-id:dlv_...na redeenv["HTTP_WEBHOOK_ID"]o nome no Rackrequest.body.readcorpo brutoverify!,depoisJSON.parsewebhook-id:dlv_...na rederequest.headers["webhook-id"]mapeado paravocêrequest.raw_postcorpo brutoWebhook#verifyOnde um header de webhook do Ikiform vai parar no Sinatra e no RailsSinatraRailswebhook-id: dlv_...na redeenv["HTTP_WEBHOOK_ID"]o nome no Rackrequest.body.readcorpo brutoverify!, depoisJSON.parsewebhook-id: dlv_...na rederequest.headers["webhook-id"]mapeado para vocêrequest.raw_postcorpo brutoWebhook#verify
  1. O Ikiform envia um header webhook-id em minúsculas.
  2. No Sinatra, o Rack o guarda como HTTP_WEBHOOK_ID no ambiente da requisição, então leia-o de lá.
  3. O request.body.read do Sinatra devolve o corpo bruto, que é verificado antes do JSON.parse.
  4. 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.
Mesmo header, duas grafias. O verificador só precisa dos três valores num hash com chaves em minúsculas.

Sinatra com 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") e pack("m0") são base64 estrito sem a biblioteca base64, 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 como Rack::JSONBodyParser nesta rota.
  • halt 400 interrompe a rota com o motivo como corpo, que então aparece no log de entregas do Ikiform, então um segredo errado aparece ali como no_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

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

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

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 não tem proteção CSRF. Se o controller herdar de ApplicationController, adicione skip_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 verifique params.to_json: o Rails já fez o parse do JSON e o mesclou em params, 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 v1 e devolve o JSON parseado.
  • perform_later coloca o trabalho na fila e head :no_content responde 204 na 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:

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

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

A versão atual da gem precisa do Ruby 3.3 ou posterior. Num Ruby mais antigo, use no controller o módulo IkiformSignature acima; ele roda no Ruby 3.0 e posteriores.

Confira se funciona

  1. Rode o arquivo Minitest. Quatro execuções, nenhuma falha.
  2. Faça o deploy atrás de uma URL https: pública e adicione-a em Webhooks no painel.
  3. Defina o segredo nas credentials ou em IKIFORM_WEBHOOK_SECRET e reinicie o servidor da aplicação.
  4. Escolha Send test. O log de entregas mostra 204, e o log do Rails mostra a requisição chegando a IkiformWebhooksController#create sem aviso de CSRF.
  5. Mude um caractere do segredo e envie outro teste. O log mostra 400 com o motivo.

Pegadinhas

ProblemaCausa e correção
missing_headers no SinatraO código leu request.env["webhook-id"]. A chave do Rack é HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken no RailsO controller herda a proteção CSRF. Use ActionController::API ou skip_forgery_protection
cannot load such file -- base64 depois de atualizar o RubyO 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 RailsAlgo fez o parse do corpo antes do controller, ou params foi recodificado. Verifique request.raw_post

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.