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:
- When you set up your webhook endpoint, the provider gives you a signing secret
- On each delivery, the provider computes an HMAC hash of the request body using that secret
- The hash goes in an HTTP header (e.g.,
Stripe-Signature,X-Hub-Signature-256) - Your handler recomputes the hash using the raw body and your copy of the secret
- 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:
- Extract the timestamp (
t) and allv1values - Build the signed payload:
{timestamp}.{raw_body} - Compute
HMAC-SHA256(secret, signed_payload) - Compare against each
v1value using timing-safe comparison - 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:
- Timestamp tolerance — reject signatures older than 5-10 minutes
- Idempotency keys — track processed event IDs and skip duplicates (see Webhook Retries and Idempotency)
- 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.