Webhook Security

Verify webhook signatures across providers

Every provider signs webhooks differently. Learn why schemes differ, where the bugs hide, and how to verify safely across Stripe, GitHub, Shopify, Slack, Twilio, and custom HMAC.

Why signature schemes differ per provider

There is no universal webhook signature standard. Each provider chose different combinations of algorithm, encoding, and header placement — historical accidents that became conventions.

Algorithm choices

Stripe and GitHub use SHA-256. Twilio uses SHA-1 (legacy). Slack uses SHA-256. Each was the best choice at the time, and changing it would break existing integrations.

Encoding differs

Stripe and GitHub use hex. Shopify uses base64. Slack uses hex with a version prefix. There is no right answer — they all work, but your code must match exactly.

Header names vary

No two providers use the same header. Stripe uses Stripe-Signature. GitHub uses X-Hub-Signature-256. Shopify uses X-Shopify-Hmac-SHA256. Your code looks up the right header.

Timestamp handling

Some providers embed a timestamp in the signature (Stripe, Slack). Others do not (GitHub, Shopify). If a timestamp is present, you must check it to prevent replay attacks.

The most common bugs

Most webhook signature failures are not algorithm problems. They are encoding or body-parsing mistakes.

Parsing the body instead of signing the raw bytes

You receive a JSON webhook, parse it with JSON.parse(), then try to sign the parsed object. The signature was computed over the raw request body — byte-perfect, before any parsing. If the body had different whitespace, the signature won't match. Always sign the raw body.

Using the whole secret key when it needs decoding

Centrali and Svix webhook secrets start with whsec_. This is a prefix. The real key is base64url-encoded after the prefix. Using the string "whsec_abc123..." as the HMAC key produces a completely different signature. Strip the prefix and decode.

Timing-based comparison instead of constant-time

Comparing signatures with == is fast for equal strings and slow for unequal ones. An attacker can time your responses and learn the signature byte-by-byte. Always use a constant-time comparison function.

Ignoring the timestamp window

Providers that include timestamps (Stripe, Slack) expect you to check that the timestamp is recent. A replay attack intercepts an old request and re-sends it. If you don't validate the timestamp, you accept it. Default windows are usually 5 minutes.

Provider reference table

Use this table to look up the exact header name and algorithm for each provider.

Clerk uses the Svix signature scheme. If a provider is not in this table, check whether they are Svix-backed or expose their algorithm in documentation.

Centrali webhook signature presets

Point any of these providers at Centrali with one field. Centrali supplies every wire-format detail automatically.

Stripe

Set provider=stripe. Centrali handles the timestamp, hex encoding, signature format, and rotation grace window. Ready on the first webhook.

GitHub

Set provider=github. Centrali verifies the sha256= prefixed hex signature. No timestamp to validate; idempotency is your responsibility.

Shopify

Set provider=shopify. Centrali decodes the base64 signature and verifies against the raw body. One field, zero configuration.

Slack

Set provider=slack. Centrali extracts the timestamp, checks recency, and verifies the v0= prefixed hex signature. Automatic replay protection.

Setup flow

  1. 1.Create an http-trigger with provider and a path, but no signing secret yet.
  2. 2.Centrali generates a stable webhook URL immediately. Register it with the provider.
  3. 3.The provider hands back the signing secret. Add it to the trigger.
  4. 4.Webhooks are verified automatically. Your function runs only on valid requests.

The raw path: custom HMAC schemes

When a provider is not in the presets, or their scheme has drifted, configure each piece separately.

{
"path": "custom-webhook",
"validateSignature": true,
"signatureHeaderName": "x-custom-signature",
"timestampHeaderName": "x-custom-timestamp",
"extractionPattern": "^v1,(.+)$",
"hmacAlgorithm": "sha256",
"hmacEncoding": "base64",
"secretEncoding": "prefixed-base64",
"signingSecret": "custom_abc123..."
}

Each field maps to the provider's wire format. Most of the time, copy the header name from their docs and use sha256 with hex or base64 encoding. Test with a real webhook before deploying.

Code examples: verification patterns

These snippets show the pattern for different providers. Notice the constant-time comparison on every one.

const crypto = require('crypto');
function verifyStripeSignature(rawBody, signature, secret) {
// Stripe-Signature format: t=timestamp,v1=signature[,v0=...]
// Extract both timestamp and v1 signature
const items = signature.split(',').reduce((acc, item) => {
const [key, value] = item.split('=');
acc[key] = value;
return acc;
}, {});
const timestamp = items.t;
const receivedSig = items.v1;
// Check timestamp is recent (5 minute default tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp outside tolerance window');
}
// Compute expected signature: HMAC-SHA256(secret, timestamp.rawBody)
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig))) {
throw new Error('Signature mismatch');
}
return true;
}

Start with a preset provider to skip the verification code.

Signature verification, solved

Set a provider preset and Centrali handles every detail — raw body verification, timestamp validation, constant-time comparison, secret rotation. One field in your trigger.

5-minute setup. No credit card.