Verify webhook signatures

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.

Preet SutharFounder, Ikiform

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.

Where an Ikiform webhook header ends up in Sinatra and in RailsSinatraRailswebhook-id:dlv_...on the wireenv["HTTP_WEBHOOK_ID"]Rack's name foritrequest.body.readraw bodyverify!,thenJSON.parsewebhook-id:dlv_...on the wirerequest.headers["webhook-id"]mapped for yourequest.raw_postraw bodyWebhook#verifyWhere an Ikiform webhook header ends up in Sinatra and in RailsSinatraRailswebhook-id: dlv_...on the wireenv["HTTP_WEBHOOK_ID"]Rack's name for itrequest.body.readraw bodyverify!, then JSON.parsewebhook-id: dlv_...on the wirerequest.headers["webhook-id"]mapped for yourequest.raw_postraw bodyWebhook#verify
  1. Ikiform sends a lowercase webhook-id header.
  2. In Sinatra, Rack stores it as HTTP_WEBHOOK_ID in the request environment, so read it from there.
  3. Sinatra's request.body.read returns the raw body, which is verified before JSON.parse.
  4. 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.
Same header, two spellings. The verifier only needs the three values in a hash with lowercase keys.

Sinatra with 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") and pack("m0") are strict base64 without the base64 library, which stopped being a default gem in Ruby 3.4 and must otherwise be added to the Gemfile.
  • OpenSSL.secure_compare is 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.read is the first read. Avoid middleware such as Rack::JSONBodyParser on this route.
  • halt 400 stops the route with the reason as the body, which then shows in Ikiform's delivery log, so a wrong secret reads as no_matching_signature there.
  • 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

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

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

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 has no CSRF protection. If the controller inherits from ApplicationController instead, add skip_forgery_protection, or Rails refuses the post.
  • request.raw_post is the body as sent, cached after the first read, so calling it twice is safe. Don't verify params.to_json: Rails has already parsed and merged the JSON into params, 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 v1 signature, and returns the parsed JSON.
  • perform_later queues the work and head :no_content answers 204 right 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:

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

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

The gem's current release needs Ruby 3.3 or later. On an older Ruby, use the IkiformSignature module above in the controller instead; it runs on Ruby 3.0 and later.

Check it works

  1. Run the Minitest file. Four runs, no failures.
  2. Deploy behind a public https: URL and add it under Webhooks in the dashboard.
  3. Set the secret in credentials or IKIFORM_WEBHOOK_SECRET and restart the app server.
  4. Choose Send test. The delivery log shows 204, and the Rails log shows the request reaching IkiformWebhooksController#create with no CSRF warning.
  5. Change one character of the secret and send another test. The log shows 400 with the reason.

Gotchas

ProblemCause and fix
missing_headers in SinatraThe code read request.env["webhook-id"]. Rack's key is HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken in RailsThe controller inherits CSRF protection. Use ActionController::API or skip_forgery_protection
cannot load such file -- base64 after upgrading RubyRuby 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 RailsSomething parsed the body before the controller, or params was re-encoded. Verify request.raw_post

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.