Webhook Signature Verification

|5 min read
Contents

Any public URL that accepts POST requests can receive forged payloads. Somebody will eventually find your webhook endpoint, and when they do, they'll send garbage to it. Signature verification is what stops those requests from doing anything.

Skipping verification is probably the most common webhook security mistake, and the annoying part is that it's genuinely easy to fix.

How HMAC Signatures Work

The basic flow is the same across almost every provider:

  1. When you set up your webhook endpoint, the provider gives you a signing secret
  2. On each delivery, the provider computes an HMAC hash of the request body using that secret
  3. The hash goes in an HTTP header (e.g., Stripe-Signature, X-Hub-Signature-256)
  4. Your handler recomputes the hash using the raw body and your copy of the secret
  5. If they match, the request is legit

The word that trips everyone up here is raw. The signature is computed over the exact bytes the provider sent. If your framework parses the JSON before you get to verify, and you try to re-serialize it, the bytes will be different and verification fails. Every time.

The Raw Body Problem

This is where most signature bugs live. In Rails, the pattern looks like this:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def stripe
    payload = request.body.read
    sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = Rails.application.credentials.dig(:stripe, :webhook_secret)

    event = Stripe::Webhook.construct_event(payload, sig_header, secret)
    # process event...
  rescue Stripe::SignatureVerificationError
    head :bad_request
  end
end

request.body.read gives you the raw bytes. If you use params or request.raw_post after middleware has already consumed the body stream, you're going to get something different. It's one of those bugs where everything works fine until it doesn't, and then you stare at it for an hour wondering what changed.

Timing-Safe Comparison

Never compare signatures with ==. String equality short-circuits on the first differing byte, which leaks information about the expected value through response timing. It's a real attack vector, not just a theoretical one.

Use a constant-time comparison instead:

ActiveSupport::SecurityUtils.secure_compare(expected, actual)
import hmac
hmac.compare_digest(expected, actual)
const crypto = require('crypto');
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual));

Provider SDKs like stripe-ruby and @octokit/webhooks handle this internally, which is a good reason to use them instead of rolling your own.

Timestamp Tolerance

Stripe and several other providers include a timestamp in the signature header to prevent replay attacks — where someone captures a legitimate webhook and re-sends it later.

Stripe's header looks like this:

Stripe-Signature: t=1614556828,v1=abc123...

The t value is when Stripe generated the signature. Stripe's SDK rejects anything older than 5 minutes by default. You can loosen this for development, but don't remove it:

Stripe::Webhook.construct_event(payload, sig_header, secret, tolerance: 600)

GitHub: SHA-256 Signatures

GitHub puts their signature in the X-Hub-Signature-256 header using HMAC-SHA256:

def verify_github_signature(payload, signature_header, secret)
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
  ActiveSupport::SecurityUtils.secure_compare(expected, signature_header)
end

GitHub's webhook docs cover this well. They still send X-Hub-Signature with SHA-1 too, but SHA-256 is what you want.

Stripe: Versioned Signatures

Stripe's scheme is a bit more involved. The header contains a timestamp and one or more versioned signatures:

t=1614556828,v1=abc123...,v1=def456...

Multiple v1 values show up during secret rotation. The verification algorithm:

  1. Extract the timestamp (t) and all v1 values
  2. Build the signed payload: {timestamp}.{raw_body}
  3. Compute HMAC-SHA256(secret, signed_payload)
  4. Compare against each v1 value using timing-safe comparison
  5. Reject if the timestamp is too old

Just use Stripe::Webhook.construct_event — it handles all of this correctly and you don't have to think about it.

Secret Rotation

When you rotate a signing secret, there's a window where both the old and new secrets are valid. Providers handle this differently:

  • Stripe sends signatures with both secrets during the rotation period, so your handler should accept either
  • GitHub requires you to switch immediately — there's no overlap window

During rotation, consider logging verification failures at a warning level rather than hard-rejecting. Once the rotation window closes, tighten back up.

Replay Attack Prevention

Without timestamp validation, a valid webhook can be re-delivered forever. An attacker who captures network traffic could replay a payment_intent.succeeded event and credit an account repeatedly.

Three layers of defense:

  1. Timestamp tolerance — reject signatures older than 5-10 minutes
  2. Idempotency keys — track processed event IDs and skip duplicates (see Webhook Retries and Idempotency)
  3. TLS — always receive webhooks over HTTPS to prevent interception in the first place

Debugging Signatures with CatchHook

CatchHook detects webhook providers automatically and shows signature verification status on every captured request. When verification fails, the diagnostics panel tells you:

  • Which header contained the signature
  • Whether the format matched the expected pattern
  • The verification algorithm the provider uses
  • Common causes (missing raw body, wrong secret, expired timestamp)

This is way faster than adding debug logging to your handler, deploying, triggering the event, and reading the logs. You can see exactly what the provider sent and compare it against what your handler expects.

If you configure your signing secret on the endpoint, CatchHook verifies signatures on every incoming request and shows the result inline. When you edit a captured payload for testing, CatchHook re-signs it with your secret so the modified version still passes verification in your handler.

Try it yourself

Start catching webhooks in under 30 seconds. No credit card required.