LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

Receiving webhooks in Rails

A webhook endpoint is the one route in a Rails application that a stranger is supposed to POST to. No session, no CSRF token, no current_user, and a body your code is about to act on. Everything that normally decides whether a request is allowed has been removed, and one HMAC comparison is holding the door.

Four things have to be right, and they are independent: the signature has to be checked, it has to be checked against the bytes that arrived, the same event arriving twice must not do the work twice, and the response has to come back before the sender gives up. Getting three of the four is the normal outcome, and the missing one is usually silent. What follows was reproduced against stripe 19.6.2, actionpack 8.1.3.1 and PostgreSQL 17.7, with the output pasted as it came out.

Verify first, parse second

The ordering inside Stripe::Webhook.construct_event is deliberate, and the gem explains itself in a comment at stripe-19.6.2/lib/stripe/webhook.rb:15:

def self.construct_event(payload, sig_header, secret,
                         tolerance: DEFAULT_TOLERANCE)
  Signature.verify_header(payload, sig_header, secret, tolerance: tolerance)

  # It's a good idea to parse the payload only after verifying it. We use
  # `symbolize_names` so it would otherwise be technically possible to
  # flood a target's memory if they were on an older version of Ruby that
  # doesn't GC symbols. It also decreases the likelihood that we receive a
  # bad payload that fails to parse and throws an exception.
  _build_v1_event(payload)
end

verify_header runs on line 13, _build_v1_event on line 20. The symbol argument is historical, and the general one is not: a before_action that parses is a before_action that ran your JSON parser over bytes from a stranger, and every parser has had a bad year. Webhook signature verification is a fixed amount of work on a fixed-size digest. Parsing is unbounded work on whatever the sender felt like sending.

The practical consequence for a Rails webhook controller is that verification belongs before anything touches params, and params is touched earlier than you think, which is the next section.

The raw bytes, and what Rails does to them

The signature is computed over the body as a byte string. Any transformation on the way in changes the digest, and the failure is total rather than partial: one different byte and nothing matches.

Two Rails methods claim to give you the body, and only one of them is repeatable. From actionpack-8.1.3.1/lib/action_dispatch/http/request.rb:362:

def body
  if raw_post = get_header("RAW_POST_DATA")
    raw_post = (+raw_post).force_encoding(Encoding::BINARY)
    StringIO.new(raw_post)
  else
    body_stream
  end
end

With RAW_POST_DATA already cached, body hands back a fresh StringIO every call. Without it, body is rack.input itself, and reading a stream twice gets you the stream twice. Here is a controller that reads it twice, once per content type:

A (json, params untouched): read1=28 read2=28 raw_post=28 read3=28
A (octet-stream):           read1=28 read2=0  raw_post=28 read3=28

Same controller, same body, same 28 bytes on the wire. The JSON request works because Rails already buffered the body to parse it into params; the application/octet-stream request has nothing cached, so the second read returns the empty string. request.raw_post is the one that is always 28, because it calls read_body_stream, which rewinds around the read and caches the result.

The other way to lose the bytes is to rebuild them. params.to_unsafe_h.to_json on a body containing a money amount and an escaped character:

raw_post    : {
  "id": "evt_1",
  "amount": 19.90,
  "note": "caf\u00e9"
}
reserialized: {"id":"evt_1","amount":19.9,"note":"café","hook":{"id":"evt_1","amount":19.9,"note":"café"}}

Three separate corruptions in one line. The trailing zero is gone because 19.90 and 19.9 are the same Float. The é escape is gone because Ruby emits the character. And ParamsWrapper has helpfully added a duplicate of the whole payload under the controller's own name. Sign over that and nothing will ever verify.

One more caveat, for a service that is not on Puma. Rack 3 dropped the requirement that rack.input be rewindable, and read_body_stream only rewinds if body_stream.respond_to?(:rewind). On a server that streams request bodies, raw_post still returns the bytes once, and the middleware that buys back the old behaviour is Rack::RewindableInput::Middleware.

What the signature is actually over

Every provider signs a string you have to reconstruct exactly, and the recipe is short enough to implement without a gem. Stripe's is the timestamp, a literal dot, and the body:

timestamped_payload = "#{timestamp.to_i}.#{payload}"
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, timestamped_payload)

That is webhook.rb:84, and reimplementing it by hand produces the same hex digest, which is worth checking once so you believe the rest:

hmac = c7c4991c43c9f82bff25263af75c86284a1d9892cdeff11b9e33c09653a3c5bb
gem  = c7c4991c43c9f82bff25263af75c86284a1d9892cdeff11b9e33c09653a3c5bb

GitHub does the same thing with a different envelope: HMAC-SHA256 over the raw body alone, sent as X-Hub-Signature-256, with a sha256= prefix on the value. Shopify uses HMAC-SHA256 base64 encoded in X-Shopify-Hmac-Sha256. The shape is always secret plus bytes, and the only real decisions are what goes into the signed string and how the result is encoded.

Two details in the comparison are not optional. The first is constant-time comparison, because == on strings returns as soon as it finds a differing byte and that timing is a measurable oracle. Rails ships one in ActiveSupport::SecurityUtils.secure_compare, which delegates to OpenSSL.fixed_length_secure_compare when it is available; Rack ships Rack::Utils.secure_compare, which does the same. The stripe gem carries its own pure-Ruby copy in Util.secure_compare, borrowed from an older Active Support, and the effect is the same.

The second is that the header can legitimately carry more than one signature. Stripe's verifier checks signatures.any? rather than a single value, and the reason is secret rotation: while a rolled secret's predecessor is still live, "your endpoint has multiple active secrets and Stripe generates one signature for each secret". A hand-rolled verifier that reads one value out of the header will start failing halfway through a rotation you thought was zero-downtime.

Here is the whole thing as a concern, provider-neutral:

module VerifiesWebhookSignature
  extend ActiveSupport::Concern

  included do
    skip_forgery_protection
    before_action :verify_signature!
  end

  private

  def verify_signature!
    head :bad_request unless signature_matches?
  end

  def signature_matches?
    received = request.headers["X-Signature-256"].to_s
    return false if received.empty?

    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, request.raw_post)
    ActiveSupport::SecurityUtils.secure_compare(expected, received)
  end
end

Two Rails defaults that answer before your code does

Nothing in the previous section runs if the request never reaches the action, and a stock Rails 8.1 application has two middlewares perfectly happy to reject a webhook on your behalf.

Forgery protection is the famous one. actionpack-8.1.3.1/lib/action_controller/railtie.rb:106 applies it to every controller inheriting from ActionController::Base:

if app.config.action_controller.default_protect_from_forgery
  protect_from_forgery with: :exception
end

A POST with no token to a controller without skip_forgery_protection raises ActionController::InvalidAuthenticityToken: Can't verify CSRF token authenticity., which your sender sees as a 422. An ActionController::API subclass never had the callback and needs no exemption.

The quieter one is host authorization. Set config.hosts in production, as the generated config/environments/production.rb encourages, and register the endpoint under a hostname that is not in the list:

Host: app.example.com    -> 200
Host: 203.0.113.9        -> 403
Host: old.example.com    -> 403

An old domain kept alive for a redirect, or a provider posting to a bare IP, gets a 403 from middleware, and the request never reaches a controller where you could log it. Redirects fail too: Stripe's own status table says "We consider redirect responses to webhook requests as failures", so a force_ssl upgrade from an http:// endpoint URL, or a trailing slash that your routes bounce, is a permanent failure dressed as a 301.

All three failures look identical from the sending end, which is a non-2xx from a route that used to work, and all three are invisible in production.log at default verbosity. When an integration "stopped receiving events" after a deploy, this is the first place to look, before anything to do with signatures.

A unique index is the idempotency check

Senders retry. Stripe "attempts to deliver events to your destination for up to three days with an exponential back off in live mode", and its own advice is to "guard against duplicated event receipts by logging the event IDs you've processed, and then not processing already-logged events". Which is right, and which almost everybody implements as a check followed by a write.

Two concurrent deliveries of one event, both asking exists? before either writes, against PostgreSQL 17.7:

exists? guard, NO unique index : markers=2 charges=2
  thread 0: ActiveRecord::RecordNotUnique
exists? guard, unique index    : markers=1 charges=1

The exists? call contributed nothing in either run. Without the index, both threads read false and both charged. The RecordNotUnique line belongs to the second run, where the loser's INSERT was refused by PostgreSQL and the rescue absorbed it. Webhook idempotency is a unique index, and the Ruby around it is a convenience.

So the ledger is a table and one index:

create_table :received_events do |t|
  t.string   :provider,    null: false
  t.string   :external_id, null: false
  t.jsonb    :payload,     null: false
  t.datetime :processed_at
  t.timestamps
end
add_index :received_events, [:provider, :external_id], unique: true

and the write is one method with the rescue as its control flow:

class ReceivedEvent < ApplicationRecord
  # Returns the new row, or nil when this delivery has been seen before.
  def self.record_once(provider:, external_id:, payload:)
    create!(provider:, external_id:, payload:)
  rescue ActiveRecord::RecordNotUnique
    nil
  end
end

provider is in the key because event IDs are only unique per sender, and two integrations both using evt_ prefixes is a collision waiting for a quiet weekend.

One honest limit on the whole approach, which the sender documents and nobody reads: "In some cases, two separate Event objects are generated and sent. To identify these duplicates, use the ID of the object in data.object along with the event.type." An event-ID ledger deduplicates deliveries. It does not deduplicate events, and no ledger can, because the two arrive with different IDs. Handlers that must not double-apply still need to be idempotent in their own terms, by checking the state they are about to write rather than trusting that they run once.

The savepoint a duplicate needs

Wrapping the marker and the work in one transaction is the right instinct: if the work fails, the marker rolls back with it, and the sender's next retry gets a clean attempt. Measured, with a handler that raises:

=== 4. handler raises: does the marker survive? ===
  raised: downstream API 503
  marker for evt_D present? false
  retry after the failure: #<SideEffect id: 4, note: "retry worked">, side_effects=1

The subtlety is which kind of transaction. A unique violation is not an ordinary exception in PostgreSQL, it aborts the enclosing transaction, and every statement after it fails until the block ends. Run the duplicate-tolerant version inside an outer transaction without a savepoint:

=== 2. same thing nested inside an outer transaction, WITHOUT requires_new ===
  delivery 1: #<SideEffect id: 2, note: "charged 0">
  delivery 2: "SKIPPED"
  outer blew up: ActiveRecord::StatementInvalid: PG::InFailedSqlTransaction:
                 ERROR:  current transaction is aborted, commands ignored until end of transaction block

The rescue caught RecordNotUnique and reported the skip, and then the WebhookEvent.count at the end of the outer transaction died anyway. requires_new: true emits a real SAVEPOINT, so the rollback is scoped to the duplicate insert and the connection stays usable:

=== 3. same, WITH requires_new inside an outer transaction ===
  delivery 1: #<SideEffect id: 3, note: "charged 0">
  delivery 2: "SKIPPED (ActiveRecord::RecordNotUnique)"
  can we still query? WebhookEvent.count = 2

Which is the whole argument for the option, and Transactions and rollback in Rails has the rest of what a savepoint costs. A webhook receiver is the most likely place in an application to hit this, because duplicate inserts are the design here rather than an edge case.

Replay protection is a clock, and it runs for five minutes

Signature verification proves the bytes came from the holder of the secret. It says nothing about when, so a captured request replays forever unless something bounds its age. The bound is a timestamp inside the signed string, and webhook.rb:154:

if tolerance && timestamp < Time.now - tolerance

with DEFAULT_TOLERANCE = 300. Reproduced:

timestamp 299s old                : OK (no raise)
timestamp 301s old                : Timestamp outside the tolerance zone (2026-09-24 16:30:30)
timestamp 10 YEARS in the FUTURE  : OK (no raise)

The comparison is one-sided. A timestamp from the future is not outside the tolerance zone, because the only test is whether it is too old. Nobody can forge one without the secret, so this is not an exploit, but it does mean a sender whose clock has run away will be accepted indefinitely and your replay window is silently unbounded in one direction.

What the window does not do is deduplicate. Stripe "generates the timestamp and signature each time we send an event to your endpoint. If Stripe retries an event (for example, your endpoint previously replied with a non-2xx status code), then we generate a new signature and timestamp for the new delivery attempt." The honest retry and the malicious replay differ only in that the retry is fresh. The clock stops an attacker sitting on a captured request; the ledger from the previous section is what stops the same event being applied twice. They are two mechanisms for two problems, and shipping one of them feels like shipping both.

The tolerance argument that does the opposite of its documentation

Stripe's docs carry a warning worth quoting exactly: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely."

In stripe-ruby 19.6.2 that is backwards, because 0 is truthy in Ruby and the guard is if tolerance && .... A one-second-old timestamp, which is what a real delivery looks like:

tolerance: 0   with a 1s-old timestamp -> Stripe::SignatureVerificationError: Timestamp outside the tolerance zone
tolerance: nil with a 1s-old timestamp -> accepted
tolerance: 300 with a 1s-old timestamp -> accepted

Passing 0 does not relax the check, it rejects every event you will ever receive. nil is the value that disables it. The warning is correct for the JavaScript and Python libraries, where zero is falsy, and it is a trap in Ruby in the opposite direction. Leave the argument off.

Answer fast, and own the retry you just gave up

Both halves of the advice here come from the sender, and they pull against each other. Stripe says your endpoint "must quickly return a successful status code (2xx) before any complex logic that could cause a timeout", and separately says to "process incoming events with an asynchronous queue", because "any large spike in webhook deliveries (for example, during the beginning of the month when all subscriptions renew) might overwhelm your endpoint hosts". A timed-out delivery counts as a failure and is retried for three days.

So the controller does no domain work at all:

class WebhooksController < ApplicationController
  include VerifiesWebhookSignature

  def create
    event = ReceivedEvent.record_once(
      provider: "acme",
      external_id: request.headers["X-Event-Id"],
      payload: JSON.parse(request.raw_post)
    )
    ProcessWebhookJob.perform_later(event.id) if event
    head :ok
  end
end

The if event is the deduplication: record_once rescues RecordNotUnique and returns nil, a redelivery enqueues nothing, and the answer is still 200, because a duplicate is not an error and answering 4xx to one buys you three days of retries for an event you already handled.

Two Rails-specific hazards sit in those few lines. The first is ordering: if the insert and the enqueue are ever inside a transaction together, note that ActiveJob::Base.enqueue_after_transaction_commit is false under load_defaults 8.1, despite perform_later's own documentation describing deferral as the behaviour. Measured with the test adapter:

enqueue_after_transaction_commit default = false
inside the transaction, enqueued jobs = 1
after rollback,       enqueued jobs = 1

A job for work that never committed, now holding a row ID that does not exist. Set the flag to true on the job class and the enqueue follows the commit, which is what you want everywhere, not just here.

The second hazard is the one you accepted on purpose. The 200 is a promise that the event is safe, and after it the sender's retry machinery is gone: a job that fails three times and dies leaves a received_events row with a null processed_at and no one looking at it. Handling events asynchronously means owning the redrive, which is one scheduled sweep over unprocessed rows older than an hour. Solid Queue vs Sidekiq covers what the queue does with the failure; the row is what tells you the failure existed.

What the boilerplate does, and where it stops

The Rails boilerplate this site sells verifies signatures and deduplicates, and it does the work inline rather than in a job. WebhooksController is the entire endpoint:

def stripe
  Billing::Webhooks::Handler.new(verified_event).call
  head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
  head :bad_request
end

def verified_event
  Stripe::Webhook.construct_event(
    request.body.read,
    request.headers["Stripe-Signature"],
    AppConfig.stripe_webhook_secret
  )
end

verified_event is evaluated before Handler#call runs, so nothing reaches a handler unverified, and the two rescued classes map cleanly onto a 400. Idempotency lives in one model method with the unique index doing the work and requires_new: true doing the savepoint, which is the shape argued for above. Unknown event types return early and are never recorded, so adding a handler later and resending the event still works.

Where it stops is the queue. The handlers are small and touch one or two rows, the billing traffic a boilerplate serves on day one is not a renewal spike, and an inline handler keeps the sender's retry as the error recovery instead of requiring a sweeper on day one. That is a defensible default and it is not the right shape at volume. Note also request.body.read rather than request.raw_post. It is correct here, because Stripe posts application/json and Rails has already buffered the body to build params, and it is one content type away from returning the empty string.

The call, and what would change it

Verify in a before_action, over request.raw_post, with a constant-time comparison, and never from params. Deduplicate on a unique index over (provider, external_id) and let RecordNotUnique be the control flow. Answer 200 to anything you have accepted, including a duplicate, and reserve non-2xx for a signature that did not check out. None of that is negotiable and all of it is about forty lines.

Do the work inline until you have a reason not to, and treat the reason as a number rather than a principle: a handler that makes an external API call, or a delivery volume that arrives in bursts, or a p99 that has started to approach the sender's timeout. Until then the sender's three days of retries are free error handling, and a queue is a second place for the work to get lost.

What would change it: a Rails default of enqueue_after_transaction_commit = true would remove the sharpest reason to be careful about ordering, and a provider that shipped a real idempotency key distinct from the delivery ID would kill the "two Event objects for one thing" caveat that makes handler-level idempotency necessary on top of the ledger.

The cost of recommending inline work is honest to state. An inline handler holds a web worker for the duration of whatever it calls, so one slow downstream API turns a renewal burst into a queue in front of Puma, and the symptom is timeouts on unrelated pages. The moment a handler talks to anything over the network, the argument flips.

What this post does not cover

Sending webhooks, which is a different problem with a different failure list: outbound retry schedules, endpoint health scoring, signing keys you have to let customers rotate, and SSRF checks on customer-supplied URLs.

Also absent: the standardwebhooks gem, version 1.1.0 released 2026-04-28, MIT, which implements the Standard Webhooks specification for senders and receivers that adopt it and is worth a look if you are designing an outbound integration, though it does not help with Stripe or GitHub, who predate the spec. IP allowlisting, which Stripe recommends alongside webhook signature verification and which is a firewall question rather than a Rails one. Replay attacks against an endpoint with no timestamp in its signed payload at all, where the only defence left is the ledger. And any latency figure, because the numbers above are byte counts, row counts and error strings, and those are the ones that reproduce on your machine.

#rails #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.