Module: ConcernsOnRails::Controllers::WebhookVerifiable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/controllers/webhook_verifiable.rb

Overview

HMAC signature verification for inbound webhooks — the receiving side of Stripe/GitHub/Shopify-style integrations. The action only runs when the signature over the raw request body verifies; otherwise a 401/400 is rendered and the action never executes.

class WebhooksController < ApplicationController
  include ConcernsOnRails::Controllers::WebhookVerifiable

  verify_webhook :stripe,  secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] },  scheme: :stripe
  verify_webhook :github,  secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] },  scheme: :github
  verify_webhook :shopify, secret: [NEW_SECRET, OLD_SECRET],             scheme: :shopify
  verify_webhook :custom,  secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
  # verify_webhook secret: ...    # no actions = catch-all (declare specific rules first)

  def stripe; ...; end
end

Schemes (header defaults in parentheses):

:github  ("X-Hub-Signature-256")    value "sha256=<hex>"
:shopify ("X-Shopify-Hmac-Sha256")  value strict-Base64 of the binary HMAC
:stripe  ("Stripe-Signature")       value "t=<unix>,v1=<hex>[,v1=...]"; the
         signed payload is "#{t}.#{body}"; every v1 is tried (rotation);
         `tolerance:` (default 300s, Stripe-only) rejects |now - t| beyond
         the window, replayed and far-future headers alike
:hex / :base64                       plain hex / strict-Base64 HMAC of the
         body; these have no standard header so `header:` is required;
         `digest:` (:sha256 default, :sha1/:sha512) applies to these only

secret: a non-blank String, a callable (instance_exec’d per request — use ‘-> { ENV }` for boot-order safety or read params for multi-tenant secrets), or an Array of those (rotation: any match passes). A secret that resolves blank at request time raises ArgumentError — a misconfigured endpoint must alert the operator, not 401 into the provider’s silent retry loop.

Failures render through ‘webhook_verification_failed(message:, status:, code:)` (delegates to Respondable’s render_error when present; override it to customize): missing/blank header -> 401 “webhook_signature_missing”; mismatch -> 401 “webhook_signature_invalid”; stale/future Stripe timestamp -> 401 “webhook_timestamp_stale”; unparseable Stripe header -> 400 “webhook_signature_malformed”. After a pass, ‘webhook_verified?` is true.

IMPORTANT:

* Include/declare this BEFORE Idempotentable (and other around filters
  that cache responses) — a 401 that runs inside Idempotentable's
  around_action would be cached and replayed for the full ttl.
  Verifying before Throttleable also stops forged traffic from burning
  legitimate rate budget.
* Webhook endpoints receive third-party POSTs: skip CSRF yourself
  (`skip_before_action :verify_authenticity_token`) along with any
  session auth filters.
* The signature covers the raw bytes — parse `request.raw_post` in the
  action; re-serializing `params` may not round-trip byte-for-byte.
  Anything that rewrites the body before the controller breaks
  verification.
* In tests, `skip_before_action :verify_webhook_signature!` or sign the
  payload for real with OpenSSL::HMAC.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

LABEL =
"ConcernsOnRails::Controllers::WebhookVerifiable".freeze
SCHEMES =
{
  hex: { header: nil, encoding: :hex },
  base64: { header: nil, encoding: :base64 },
  github: { header: "X-Hub-Signature-256", encoding: :hex, prefix: "sha256=" },
  shopify: { header: "X-Shopify-Hmac-Sha256", encoding: :base64 },
  stripe: { header: "Stripe-Signature", encoding: :stripe }
}.freeze
PINNED_DIGEST_SCHEMES =

Schemes whose wire format pins the digest — ‘digest:` cannot override it.

%i[github shopify stripe].freeze
SUPPORTED_DIGESTS =
{ sha256: "SHA256", sha1: "SHA1", sha512: "SHA512" }.freeze
STRIPE_DEFAULT_TOLERANCE =

seconds; Stripe’s recommended window

300
MAX_STRIPE_SIGNATURES =

Stripe sends at most two v1 values (during secret rolls); the cap is cheap hygiene against a header stuffed with thousands of candidates.

16
STRIPE_TIMESTAMP_FORMAT =
/\A\d+\z/

Instance Method Summary collapse

Instance Method Details

#verify_webhook_signature!Object

before_action entry point. Public and named so apps can ‘skip_before_action :verify_webhook_signature!` (e.g. in tests).



158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 158

def verify_webhook_signature!
  rule = webhook_rule_for_action
  return unless rule

  value = read_webhook_header(rule)
  if value.nil?
    return webhook_verification_failed(message: "#{rule[:header]} header is missing.",
                                       status: :unauthorized, code: "webhook_signature_missing")
  end

  secrets = resolve_webhook_secrets!(rule)
  webhook_render_outcome(rule, webhook_verification_outcome(rule, value, secrets))
end

#webhook_verification_failed(message:, status:, code:) ⇒ Object

Single funnel for all failure outcomes (override point). Uses Respondable’s render_error when available, otherwise the same inline envelope as Throttleable / Idempotentable.



180
181
182
183
184
185
186
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 180

def webhook_verification_failed(message:, status:, code:)
  return unless respond_to?(:response) && response

  return render_error(message: message, status: status, code: code) if respond_to?(:render_error)

  render json: { success: false, error: { message: message, code: code } }, status: status
end

#webhook_verified?Boolean

True once the current request’s signature has verified.

Returns:

  • (Boolean)


173
174
175
# File 'lib/concerns_on_rails/controllers/webhook_verifiable.rb', line 173

def webhook_verified?
  !!@webhook_verified
end