Verify the sender — HMAC-signed webhook delivery
Anyone on the internet can send a POST to your endpoint. Signing proves the request came from the provider you trust. Learn how to implement verification without getting the details wrong.
Why webhook signatures matter
A webhook endpoint is a public HTTP path you expose. Any attacker who knows the URL can POST whatever they want to it. If your webhook handler creates orders, charges credit cards, or updates user data without verification, you've handed them the keys.
Spoofed requests
Without signature verification, an attacker sends a fake webhook that your system treats as real. The signature proves it came from the provider you trust.
Replay attacks
An attacker captures an old signed request and sends it again. Signature schemes include a timestamp to reject replays older than your tolerance window.
Man-in-the-middle tampering
HMAC-SHA256 over the raw request body prevents tampering in transit. If a single byte changes, the signature no longer matches.
How webhook signing works
Every provider uses HMAC-SHA256, but wire formats differ. The key details: the body is hashed before parsing, the secret must be stripped and decoded before use, and comparison must be timing-safe.
The three-step verification
Extract signature from header
The provider sends the signature in a header — Stripe uses X-Stripe-Signature, GitHub uses X-Hub-Signature-256, Shopify uses X-Shopify-Hmac-SHA256. The header name and format are provider-specific.
Hash the raw request body
Compute HMAC-SHA256 over the *raw bytes* received from the network, before JSON parsing. Parse-then-hash is the most common mistake and will never match.
Timing-safe compare
Compare the computed hash to the signature from the header using a constant-time comparison. A naive string comparison leaks timing information attackers can exploit.
Four mistakes that break verification
Hashing the parsed JSON
JSON parsers normalize whitespace and key order. The signature was computed over the exact bytes received, not the normalized structure. Use req.rawBody, not JSON.stringify(body).
Forgetting to decode the secret
Secrets arrive base64-encoded (Stripe prefix: whsec_, GitHub prefix: sha256=). Decode first. Using the encoded string as the HMAC key produces a signature that never matches.
Plain string comparison
Comparing with == or string equality leaks timing. A 32-character signature has 256 possible first characters; an attacker brute-forcing can measure how long your comparison takes on each guess.
Rejecting old signatures after rotation
Some providers keep the previous secret valid for a grace window after a rotation — Stripe, for example, lets you delay the old secret's expiry by up to 24 hours, and signs with every active secret during that time. Others expire the old secret the moment you replace it. Check your provider before assuming either, and make sure your verification code tries the previous secret rather than rejecting outright.
Secret rotation without downtime
When you suspect a secret was compromised or want to rotate for hygiene, you need the old secret to stay valid briefly so in-flight requests don't fail.
The safe rotation flow
The safe pattern is a dual-validation window: the new secret becomes active immediately, the old one stays valid for a defined period, and requests signed with either key are accepted so no legitimate webhook fails mid-rotation. On Centrali's outbound deliveries that window is 24 hours, and both keys sign during it. Inbound, the window is whatever your sender implements — Stripe lets you choose up to 24 hours; other providers cut over immediately.
1. Generate a new secret (provider dashboard or API call)
2. The sender starts signing with the new key immediately
3. During the grace window, both old and new keys verify
4. When the window closes, the old secret stops working
5. Legitimate requests signed during the rotation window still pass verification
In your verification code: Store both the current secret and the previous secret (if one exists). Try verification with the current secret first, then the previous one if the first fails. Keep the previous secret for as long as your sender's grace window lasts, then drop it.
Provider presets handle the differences
Every provider differs in header names, encoding, and signature format. Rather than hardcoding these details, Centrali includes presets for the most common senders.
| Provider | Header | Format |
|---|---|---|
| Stripe | X-Stripe-Signature | t=<timestamp>,v1=<base64-signature> |
| GitHub | X-Hub-Signature-256 | sha256=<hex-signature> |
| Shopify | X-Shopify-Hmac-SHA256 | <base64-signature> |
| Slack | X-Slack-Request-Timestamp + X-Slack-Request-Signature | v0=<hex-signature> |
| Svix-backed senders (Clerk, Resend, Loops, OpenAI, Brex) | svix-id, svix-timestamp, svix-signature | v1,<base64-signature> |
Each preset supplies the header name, extraction pattern, algorithm, encoding, and optional timestamp tolerance. Set the provider and signing secret, and the verification happens automatically.
Your function receives verified requests only
Signature verification happens before your function runs. The request body in executionParams.payload has already been verified by Centrali.
async function run() {const event = executionParams.payload;// By this line, Centrali has already verified the signature.// You don't need to verify it again — the request is guaranteed authentic.const record = await api.createRecord('inbound-events', {provider: event.provider,eventType: event.type,payload: event,receivedAt: new Date(),});return { success: true, recordId: record.id };}
Centrali verifies the signature at the HTTP layer — before JSON parsing, before your function runs. You focus on the business logic; we handle the cryptography.
Step-by-step setup for GitHub webhooks
Centrali vs. rolling your own
Signature verification is straightforward in concept but error-prone in practice. The four most common mistakes lead to security gaps or integration failures.
Rolling your own
- Remember to hash the *raw* request body, not parsed JSON
- Decode the secret from the correct encoding (base64? base64url? plain?)
- Use crypto.timingSafeEqual, not == or string equality
- Handle secret rotation gracefully with a dual-validation window
- Support multiple providers — each with different header names and formats
- Test timing-safe comparison against timing attacks
Centrali
- Set provider and secret once
- Verification happens at the HTTP layer
- Invalid signatures are rejected before your function runs
- Secret rotation handled automatically
- Built-in presets for Stripe, GitHub, Shopify, Slack, and Svix-backed senders
- Raw path available for custom schemes
Related pages
Inbound webhook verification is the first step. The rest of the flow — storing events, triggering functions, sending webhooks to your customers — lives in other pages.
Verify Webhook Signatures
Cross-provider verification guide with provider table and raw-body gotchas
Webhook Event Storage
Store signed webhooks as queryable records that outlive the sender's retention window
Webhook Replay and DLQ
Manually replay failed webhooks and inspect the dead-letter queue
Send Webhooks to Customers
Emit your own webhooks with HMAC signing, retries, and delivery logs
Best Webhook Infrastructure Tools
Compare Centrali with Svix, Hookdeck, and other webhook platforms
When you might need a dedicated webhook service
Centrali handles inbound webhook signature verification, storage, and triggering as part of your backend platform. If webhooks ARE your product — you sell webhooks-as-a-service, offer white-label customer portals, or need per-tenant embedded Event Logs — dedicated services like Svix and Hookdeck are built for that scale. Centrali is for teams where webhooks are one piece of a larger backend.
Verify your first webhook today
Sign up, create a workspace, and set up signature verification in under 5 minutes. No credit card required.
5-minute setup. No credit card.
Read the verification guide →