Webhook Signature Verification Across Multiple Providers

Webhook endpoints are publicly accessible by design. Any party with your URL can POST to it, and your application has no way to distinguish a legitimate provider request from a forgery unless you implement signature verification correctly. That distinction carries real consequences: a forged payment_intent.succeeded event can grant product access to someone who never paid; a forged customer.subscription.deleted can cut off a paying customer. Get it wrong in either direction and you either have a security hole or an angry support queue.
Signature verification solves one specific problem. The provider computes an HMAC of the request body using a shared secret, sends the result in a request header, and you recompute it independently and compare. Matching values mean the request is authentic and the payload arrived intact. Non-matching values mean you reject it. The secret never travels over the wire; only the signature derived from it does. Think of it like a wax seal on a letter: it tells you the letter hasn't been tampered with and who sent it, but it says nothing about when the letter was written or whether you've already read it.
Where teams get overconfident is in what verification does not cover. HMAC provides zero proof that an event is recent. A captured valid request replayed three weeks later will still verify, because the secret hasn't changed. Duplicate delivery is normal provider retry behavior, and HMAC does nothing about it. Whether an event makes sense given your application's current state is entirely your problem to solve.
Most providers close the replay gap by embedding a timestamp directly in the signed material and requiring receivers to reject requests outside a tolerance window. The word "embedded" is doing real work there. If the timestamp lives in a separate header and isn't part of what was signed, an attacker can substitute a fresh timestamp while keeping the old signature intact, and verification passes. The timestamp has to be inside the cryptographic commitment.
Signature verification, timestamp validation, and idempotency solve three distinct problems. None substitutes for the others.
The Two Cryptographic Approaches Providers Have Settled On
HMAC-SHA256 is the working standard. Stripe, GitHub, Shopify, Slack, and Square all use it, along with the large majority of webhook-emitting SaaS providers. Fast computation, a 256-bit output, symmetric key simplicity, and universal runtime support explain its dominance. SHA-256 remains cryptographically sound.
HMAC-SHA1 persists in legacy contexts, and Twilio still uses it. SHA-1 is considered cryptographically broken by current standards. If you're auditing older integrations, this is the first thing to check, not because it's theoretical but because it's a documented vulnerability class. Any new implementation should prefer SHA-256 at minimum. Using HMAC-SHA1 in a new integration today is like installing a deadbolt and leaving the window open.
Asymmetric signing is the minority path, and the structural difference matters more than people realize. Discord uses Ed25519; PayPal and SendGrid offer asymmetric options. With HMAC, anyone holding the secret can produce a valid signature, including your own code. With asymmetric signing, the provider signs with a private key and you verify with the public key. Only the private key holder can produce the signature, which is a non-repudiation guarantee HMAC simply cannot offer.
Despite that stronger guarantee, asymmetric verification hasn't achieved widespread adoption. Key rotation complexity is the likeliest reason. Platforms that expose public keys through a /.well-known/jwks.json endpoint make rotation tractable, but most providers haven't made that investment. For teams integrating common SaaS providers, assume HMAC-SHA256 with provider-specific variations in header names, encoding, and timestamp handling. The underlying algorithm rarely varies.
Where the Implementations Diverge: Headers, Encoding, and What Goes Into the Signed String
The algorithm is the same across most providers. What differs is construction, and construction details fail silently.
Stripe
Stripe's signature header: Stripe-Signature: t=1700000000,v1=5257a.... A comma-separated list of key-value pairs, designed to be extensible without breaking older clients. The signed string is timestamp.raw_payload, a literal dot separating the two. Encoding is hex. Default replay tolerance is five minutes.
Stripe's rotation model deserves specific attention. During rotation, both the old and new secrets are simultaneously active for up to 24 hours. The official SDK handles multi-secret verification automatically. If you hand-rolled your implementation, you need to replicate this explicitly; otherwise you will drop legitimate events during the rotation window, and they won't announce themselves as rotation-related failures.
Use the official SDK. stripe.Webhook.constructEvent handles constant-time comparison, timestamp tolerance, and header parsing correctly.
GitHub
GitHub sends X-Hub-Signature-256 with the value prefixed sha256=. No timestamp is embedded in the signed material, which means no built-in replay window. GitHub's documentation explicitly prohibits plain == comparison and requires crypto.timingSafeEqual or its equivalent. The legacy X-Hub-Signature header using SHA-1 is still sent for backward compatibility but is deprecated and should not be used.
Shopify
Shopify sends X-Shopify-Hmac-SHA256. The encoding is base64, not hex. This is the single most common confusion point when porting verification code from GitHub or Stripe. No sha256= prefix. If you copy a GitHub implementation and change only the header name without changing .digest('hex') to .digest('base64'), your verification will silently fail on every request. No timestamp in the signed string, no built-in replay protection.
Slack
Slack sends two headers: X-Slack-Signature: v0=abc123... and a separate X-Slack-Request-Timestamp. The base string is v0:{timestamp}:{body}, with the timestamp embedded in the signed material. Requests older than five minutes are rejected. Replay protection is structural here, not bolted on. Clock skew will cause verification failures; NTP sync is not optional.
Twilio
Twilio sends X-Twilio-Signature using HMAC-SHA1 encoded in base64. The signed material includes the exact URL Twilio was configured to call, including protocol, hostname, and full path. Any mismatch between the configured URL and what your server sees produces a different signature, even if the body is identical. Reverse proxies and load balancers that rewrite headers create exactly this failure mode, and it surfaces at the worst possible moment. No timestamp in the signed string; no built-in replay protection.
Discord
Discord is the exception. It uses Ed25519 public-key cryptography, not HMAC. Headers are X-Signature-Ed25519 and X-Signature-Timestamp. Verification uses Discord's public key; there is no shared secret. The timestamp is part of the signed material. Treat this as a separate implementation, not a variation on the HMAC pattern, because structurally it isn't one.
Other Providers
Adyen scopes one HMAC key per endpoint; separate endpoints require separate secrets. Dropbox sends X-Dropbox-Signature using HMAC-SHA256 with no timestamp. Square constructs its signed string as a composite of the URL and body, encoded in base64. SendGrid defaults to IP allowlisting rather than cryptographic signatures, with opt-in Ed25519 signed event webhooks for teams that need stronger verification.
The Four Implementation Mistakes That Produce Silent Failures or Silent Vulnerabilities
Raw Body vs. Parsed Body
HMAC is byte-exact. The provider signed the raw bytes of the request body. If your framework parses the JSON before your verification code runs, any re-serialization can silently alter whitespace or key ordering, producing a different byte sequence and a failed comparison. In Express, use express.raw() on the webhook route before any JSON middleware. In Django, use request.body, not request.data.
I've seen this in production more times than I'd like. It works fine locally, where framework-level body parsing middleware is often inactive or minimal, then breaks silently once deployed behind a real application stack. Nothing throws an error. The signature just never matches, and it takes an embarrassingly long time to identify why. It's like a game of telephone where the message arrives perfectly but you're grading it against the original — any paraphrase along the way and the whole thing fails.
Non-Constant-Time Comparison
Standard string comparison returns false at the first mismatched byte, which means response time leaks information about how many bytes matched. That's enough for a patient attacker to guess a valid signature byte by byte through a timing side-channel. Use crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python. GitHub's documentation calls this out explicitly. This is a real, exploited attack class, not an academic edge case.
Encoding Mismatches Between Providers
GitHub produces hex with a sha256= prefix. Shopify produces base64 with no prefix. Twilio produces base64. Slack produces hex with a v0= prefix. Mix these up and verification fails silently: the code runs without error and always returns false. Worse, a malformed comparison can always return true. When onboarding a new provider, confirm the encoding format against the provider's specification before porting any existing code.
Skipping Timestamp Validation
Without timestamp validation, a captured valid request is replayable indefinitely. The signature remains valid because the secret hasn't changed. The timestamp must be inside the signed material for this check to carry any security value; a timestamp in a separate unsigned header is not replay protection.
The operational tension is with provider retries. If a provider retries a failed delivery after your tolerance window has closed, the timestamp check rejects it. You need to decide explicitly: widen the tolerance window, or rely on idempotency keys as the primary duplicate-delivery mechanism. Both are defensible choices. The failure mode is not making a choice at all, then discovering the gap during a production incident.
Managing Secrets Across Multiple Providers Without Creating New Risk
Each provider issues a distinct signing secret scoped to a specific endpoint. Adyen makes this structurally explicit by requiring a separate HMAC key per endpoint. Every other provider's secrets should be treated the same way, even when the provider doesn't enforce it.
Signing secrets belong in a secrets manager: AWS Secrets Manager, HashiCorp Vault, Doppler, or equivalent. Not in environment variables committed to source control, and not hardcoded across multiple services. The operational difference becomes apparent at rotation time. Rotating a leaked secret stored in a single secrets manager reference is one operation. Rotating one hardcoded across a dozen services is an incident, with all the coordination overhead and verification risk that implies. Knock knock. Who's there? Your leaked webhook secret. Your leaked webhook secret who? Exactly — you have no idea, and neither does your production system.
One secret per environment is also a failure mode worth naming. A development secret that matches production means a compromised development environment can produce valid signatures for production webhooks. Scope secrets at both the environment level and the endpoint level. If a single endpoint's secret is leaked, only that endpoint's traffic is exposed; the blast radius stays contained.
Stripe's rotation model, where old and new secrets are simultaneously valid for a defined window, is the right pattern for any hand-rolled multi-provider implementation. If you don't replicate this, you will reject legitimate events during rotation.
Building a Multi-Provider Verification Layer That Doesn't Require Per-Provider Special Cases Everywhere
The differences across providers are real but bounded: header name, algorithm, encoding format, whether the timestamp is embedded, and the structure of the signed string. Everything else follows the same underlying HMAC pattern. That bounded set of differences maps cleanly to a configuration-driven abstraction, which is exactly how you stop the per-provider special cases from metastasizing.
The verification layer worth building has three parts. A provider-keyed configuration object capturing header name, algorithm, encoding, timestamp extraction logic, and tolerance window for each provider. A single verification function that accepts raw body, request headers, and provider configuration, returning a verified payload or throwing. Timestamp validation and replay checks applied inside this layer, not scattered across individual route handlers where they get forgotten the third time someone adds a new endpoint at 11pm.
Use official SDKs where they exist. Stripe's SDK handles constant-time comparison, multi-secret rotation, and timestamp tolerance correctly. For providers without SDKs, the abstraction layer makes per-provider configuration explicit and auditable in one place, rather than embedded in individual route handlers where it drifts quietly over time.
Idempotency is complementary to signature verification, not a substitute for it. Signature verification determines whether a request should be processed. Idempotency keys, implemented as stored event IDs checked before processing, prevent duplicate side effects from retried deliveries that pass verification. Adding a new provider, done right, should be a configuration entry, not a new implementation.


