class WebhookSignature
class VerificationError < StandardError; end
TOLERANCE = 300 # seconds
def initialize(payload:, header:, secrets:)
@payload = payload
@header = header.to_s
@secrets = Array(secrets).reject(&:blank?)
end
def verify!
timestamp, signatures = parse_header
check_timestamp!(timestamp)
signed_payload = "#{timestamp}.#{@payload}"
valid = expected_signatures(signed_payload).any? do |expected|
signatures.any? { |given| secure_compare(expected, given) }
end
raise VerificationError, "no matching signature" unless valid
true
end
private
def parse_header
parts = @header.split(",").map { |p| p.split("=", 2) }.to_h
timestamp = Integer(parts["t"], exception: false)
signatures = parts.select { |k, _| k == "v1" }.values
raise VerificationError, "malformed header" if timestamp.nil? || signatures.empty?
[timestamp, signatures]
end
def check_timestamp!(timestamp)
drift = (Time.now.to_i - timestamp).abs
raise VerificationError, "timestamp outside tolerance" if drift > TOLERANCE
end
def expected_signatures(signed_payload)
@secrets.map do |secret|
OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
end
end
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
ActiveSupport::SecurityUtils.secure_compare(a, b)
end
end
class WebhooksController < ActionController::API
before_action :verify_signature!
def create
event = JSON.parse(raw_body, symbolize_names: true)
record_and_process(event)
head :ok
rescue JSON::ParserError
head :bad_request
end
private
def verify_signature!
WebhookSignature.new(
payload: raw_body,
header: request.headers["X-Signature"],
secrets: Rails.application.credentials.dig(:webhooks, :signing_secrets)
).verify!
rescue WebhookSignature::VerificationError => e
Rails.logger.warn("webhook rejected: #{e.message}")
head :unauthorized
end
def record_and_process(event)
WebhookEvent.create!(
event_id: event.fetch(:id),
event_type: event.fetch(:type),
payload: event
)
ProcessWebhookJob.perform_later(event.fetch(:id))
rescue ActiveRecord::RecordNotUnique
# duplicate delivery: already recorded, return 200 to stop retries
end
def raw_body
@raw_body ||= request.raw_post
end
end
class CreateWebhookEvents < ActiveRecord::Migration[7.1]
def change
create_table :webhook_events do |t|
t.string :event_id, null: false
t.string :event_type, null: false
t.jsonb :payload, null: false, default: {}
t.datetime :processed_at
t.timestamps
end
# uniqueness constraint is what makes duplicate deliveries a safe no-op
add_index :webhook_events, :event_id, unique: true
add_index :webhook_events, :event_type
end
end
Webhooks are unauthenticated HTTP requests from a third party, so the receiver must prove two things before trusting a payload: that the body was signed by someone holding the shared secret, and that the request is not a stale copy replayed by an attacker who captured it. This snippet shows the standard scheme used by providers like Stripe and GitHub — an HMAC over timestamp.body plus a freshness window — implemented as a reusable verifier and wired into a Rails controller.
The WebhookSignature verifier class carries the whole security decision. verify! first parses the signature header into a timestamp and a set of candidate signatures, which lets the code support multiple secrets during a key rotation. check_timestamp! rejects anything outside TOLERANCE seconds of now; this is what defeats replay attacks, since a captured request only stays valid for five minutes. The signed payload is deliberately "#{timestamp}.#{body}" — binding the timestamp into the signed material means an attacker cannot reuse an old signature with a fresh timestamp.
The critical detail is secure_compare: signature checks must be constant-time to avoid timing side-channels that leak how many leading bytes matched. Ruby's ActiveSupport::SecurityUtils.secure_compare does exactly this, and comparing hex digests of equal length keeps it safe. expected_signatures recomputes an HMAC per configured secret so that both the current and previous secret validate during rotation.
In WebhooksController, verify_signature! runs as a before_action and reads the raw body via request.raw_post rather than the parsed params — signatures cover the exact bytes sent, and re-serializing parsed JSON would change whitespace and break the digest. A failed VerificationError returns 401 and never reaches business logic. After verification, record_and_process enforces idempotency: providers retry on any non-2xx, so the same event_id can arrive several times. Inserting a uniqueness-constrained WebhookEvent and rescuing RecordNotUnique makes duplicate deliveries a no-op that still returns 200, stopping the retry loop.
The trade-off is that raw-body access requires care with middleware that consumes the stream, and the tolerance window balances clock skew against replay exposure. This pattern is the baseline any production webhook endpoint should implement before doing real work.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
Share this code
Here's the card — post it anywhere.