Verify webhook signatures5 min read
Verify webhook signatures in Ruby and Rails
Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
Read the raw body (request.body.read in Sinatra, request.raw_post in Rails) and the three webhook-* headers. Compute OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") with the base64-decoded whsec_ secret as the key, base64-encode it, and compare it with each v1 signature using OpenSSL.secure_compare. In Rails, the standardwebhooks gem does the whole check in Webhook#verify.
Sinatra gets a dependency-free verifier with a Minitest suite; Rails gets the standardwebhooks gem in an API controller and a job that copes with repeats. Either verifier works in either framework, and in Hanami or Roda too, since all of them sit on Rack.
What's different in Ruby
Rack turns every request header into an environment key: webhook-id becomes HTTP_WEBHOOK_ID. Rails hides that behind request.headers["webhook-id"], which maps the name for you. Sinatra doesn't, so request.env["webhook-id"] is always nil, and a verifier that receives nil headers fails with missing_headers even though Ikiform sent them. Rails has its own version of the body trap: it parses a JSON body into params because of the application/json content type, and wrap_parameters may copy the keys under the controller's name. Neither changes request.raw_post, which is the only value to verify.
- Ikiform sends a lowercase webhook-id header.
- In Sinatra, Rack stores it as HTTP_WEBHOOK_ID in the request environment, so read it from there.
- Sinatra's request.body.read returns the raw body, which is verified before JSON.parse.
- In Rails, request.headers maps the header name to the Rack key for you, and request.raw_post returns the raw body for the gem to verify.
Sinatra with 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")andpack("m0")are strict base64 without thebase64library, which stopped being a default gem in Ruby 3.4 and must otherwise be added to the Gemfile.OpenSSL.secure_compareis constant-time and needs Ruby 3.0 or later. It hashes both values first, so different lengths are fine.- Building the signed string from an ASCII id and a binary body is safe: Ruby keeps the body's bytes as they are.
- Sinatra doesn't parse JSON bodies, so
request.body.readis the first read. Avoid middleware such asRack::JSONBodyParseron this route. halt 400stops the route with the reason as the body, which then shows in Ikiform's delivery log, so a wrong secret reads asno_matching_signaturethere.- Rack 3 no longer promises that the request body can be rewound, so read it once into a string and use that string for both verifying and parsing.
A Minitest test
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
endRun it with ruby ikiform_signature_test.rb. It uses the test vector with a fixed now. JSON.pretty_generate is how a body gets re-serialized in Ruby, so that case shows the check would catch it. In a project that uses Bundler, add minitest to the Gemfile's test group: it ships with Ruby as a bundled gem, and Bundler only loads gems the Gemfile lists.
Rails with the standardwebhooks gem
Add gem "standardwebhooks" to the Gemfile and run bundle install. Store the secret with bin/rails credentials:edit under ikiform.webhook_secret, or read it from 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::APIhas no CSRF protection. If the controller inherits fromApplicationControllerinstead, addskip_forgery_protection, or Rails refuses the post.request.raw_postis the body as sent, cached after the first read, so calling it twice is safe. Don't verifyparams.to_json: Rails has already parsed and merged the JSON intoparams, and the bytes differ.- The gem reads the headers from a hash with exact lowercase keys, so build it as shown. It checks the timestamp and every
v1signature, and returns the parsed JSON. perform_laterqueues the work andhead :no_contentanswers204right away, well inside Ikiform's 15 seconds. The job can skip delivery ids it has already seen.
Skipping repeats in the job
Answering 204 right away and doing the work in a job is the right shape, and it means the job must cope with the same delivery twice: when Ikiform doesn't get your answer within 15 seconds, it retries with the same webhook-id, and your controller queues a second job. A unique index makes the second one a no-op:
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
endThe insert and the work share a transaction, so if the mail fails the id is rolled back and the job's own retry runs again. RecordNotUnique comes from the database's unique index, which holds even when two jobs run at the same moment on different workers; a model-level validates :delivery_id, uniqueness: true alone would not. SubmissionMailer stands in for whatever the job does with the fields.
IkiformSignature module above in the controller instead; it runs on Ruby 3.0 and later.Check it works
- Run the Minitest file. Four runs, no failures.
- Deploy behind a public
https:URL and add it under Webhooks in the dashboard. - Set the secret in credentials or
IKIFORM_WEBHOOK_SECRETand restart the app server. - Choose Send test. The delivery log shows
204, and the Rails log shows the request reachingIkiformWebhooksController#createwith no CSRF warning. - Change one character of the secret and send another test. The log shows
400with the reason.
Gotchas
| Problem | Cause and fix |
|---|---|
missing_headers in Sinatra | The code read request.env["webhook-id"]. Rack's key is HTTP_WEBHOOK_ID |
ActionController::InvalidAuthenticityToken in Rails | The controller inherits CSRF protection. Use ActionController::API or skip_forgery_protection |
cannot load such file -- base64 after upgrading Ruby | Ruby 3.4 moved base64 out of the default gems. Add it to the Gemfile, or use pack("m0") as above |
NoMethodError: undefined method 'secure_compare' | Ruby older than 3.0. Upgrade, or use Rack::Utils.secure_compare, which returns false for different lengths |
| The signature fails only on Rails | Something parsed the body before the controller, or params was re-encoded. Verify request.raw_post |
Questions
Use request.raw_post. By the time your action runs, Rails has parsed a JSON body into params, but raw_post still holds the bytes exactly as sent.
Read it from the Rack environment, upper-cased with dashes turned into underscores and HTTP_ in front: webhook-id is request.env["HTTP_WEBHOOK_ID"].
Yes, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) takes the whsec_ secret and a hash with lowercase header keys, and raises WebhookVerificationError when a check fails.
Only if the controller inherits from ActionController::Base. ActionController::API controllers have no CSRF check. The signature is the protection for this action either way.
Standard Webhooks signatures are base64, not hex. Use OpenSSL::HMAC.digest to get raw bytes and encode them with [digest].pack("m0"), then compare with the value after v1, in the header.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.