ruby 107 lines · 3 tabs

Robust Webhook Verification (HMAC + Timestamp)

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
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

jwt authentication api
by Kai Nakamura 2 tabs
ruby
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

hmac api-signing webhooks
by Kai Nakamura 2 tabs
typescript
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

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab

Share this code

Here's the card — post it anywhere.

Robust Webhook Verification (HMAC + Timestamp) — share card
Link copied