Your Webhook URL Is Public. Act Like It.
Picture this: your payment processor sends a payment.completed webhook to https://api.yourapp.com/webhooks/payments. Your handler marks the order as paid, unlocks the product, and sends a confirmation email. It works perfectly.
Now imagine someone discovers that URL. They craft a POST request with a fake payment.completed payload, pointing at a high-value order. Your system processes it, marks the order as paid, and ships the product. No money ever changed hands. I've seen this happen to teams running in production for months before anyone noticed.
The fix is signature verification, and every major webhook provider supports it. The problem is that each provider does it slightly differently, and the details matter. A subtle mistake in your verification logic can leave you just as exposed as having no verification at all.
How HMAC-SHA256 Actually Works
HMAC stands for Hash-based Message Authentication Code. The idea is straightforward: you and the webhook provider share a secret key. When the provider sends you a webhook, they compute a hash of the request body using that secret and attach the result as a header. You do the same computation on your end. If the hashes match, the payload hasn't been tampered with and it came from someone who knows the secret.
The "SHA256" part refers to the hash function used inside the HMAC construction. SHA256 produces a 256-bit (32-byte) output, typically represented as a 64-character hex string. The shared secret is the key that makes the hash unique to your account. Without it, an attacker cannot produce a valid signature even knowing the exact payload contents.
That's really all there is to the mechanism. The complexity comes from the differences in how providers format the signature header, what exactly gets signed, and the encoding of the output.
Stripe: Timestamps and the Raw Body Problem
Stripe's signature scheme is one of the more thoughtful implementations. They include a timestamp in the signed content to prevent replay attacks, and they send the signature in a structured Stripe-Signature header that looks like t=1614556828,v1=5257a869....
The tricky part with Stripe is that you must verify against the raw request body, not a parsed-and-re-serialized JSON object. If your framework parses the body into an object and you call JSON.stringify() on it, the output may differ from the original bytes in whitespace, key ordering or Unicode escaping. The signature will fail. In practice, this is the number one reason teams struggle with Stripe verification.
import crypto from 'crypto';
interface StripeSignatureParts {
timestamp: string;
signatures: string[];
}
function parseStripeSignature(header: string): StripeSignatureParts {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.slice(2) ?? '';
const signatures = parts
.filter(p => p.startsWith('v1='))
.map(p => p.slice(3));
return { timestamp, signatures };
}
function verifyStripeWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300
): boolean {
const { timestamp, signatures } = parseStripeSignature(signatureHeader);
// Reject webhooks older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > toleranceSeconds) {
throw new Error(`Webhook timestamp too old: ${age}s`);
}
// Stripe signs the string "timestamp.rawBody"
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return signatures.some(sig =>
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}
Notice the 300-second (5-minute) tolerance window. Stripe recommends this value, and I agree with it. Too tight and clock drift between your server and Stripe's causes false rejections. Too loose and you're giving attackers a wider replay window. Five minutes is a reasonable tradeoff.
GitHub: The X-Hub-Signature-256 Header
GitHub takes a simpler approach. They compute an HMAC-SHA256 of the raw body and send it in the X-Hub-Signature-256 header, prefixed with sha256=. No timestamp, no structured header format. The upside is simpler verification code. The downside is no built-in replay protection, so you track processed delivery IDs yourself if that matters for your use case.
import crypto from 'crypto';
function verifyGitHubWebhook(
rawBody: string,
signatureHeader: string,
secret: string
): boolean {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
One thing to watch for: GitHub also sends an older X-Hub-Signature header using SHA-1. Ignore it. SHA-1 has known collision vulnerabilities and should not be used for signature verification in 2026. Always use the X-Hub-Signature-256 header.
Shopify: Base64 Instead of Hex
Shopify uses the same HMAC-SHA256 algorithm but encodes the output as base64 instead of hex. They send the signature in the X-Shopify-Hmac-Sha256 header. It's a small difference, but if you're copying verification code from a Stripe example and forget to change digest('hex') to digest('base64'), your verification will silently fail on every single request.
import crypto from 'crypto';
function verifyShopifyWebhook(
rawBody: string,
hmacHeader: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(expected)
);
}
I prefer the hex encoding approach that Stripe and GitHub use. Base64 is more compact (44 characters vs 64 for a SHA256 digest), but hex is easier to eyeball in logs when debugging, and the length difference is negligible in an HTTP header.
Why String Comparison Will Burn You
Every code example above uses crypto.timingSafeEqual instead of ===. Here's why that matters.
When you compare two strings with ===, most runtimes short-circuit on the first mismatched character. Comparing "abcdef" to "axcdef" returns false faster than comparing "abcdef" to "abcdex", because the mismatch is found earlier. An attacker can measure those differences, which are nanoseconds to microseconds wide, and reconstruct the correct signature one character at a time. This is called a timing attack.
Is this a realistic attack vector over the internet, where network jitter dwarfs timing differences? Honestly, it's debatable. Research papers have demonstrated it over LAN, and statistical analysis can compensate for network noise given enough requests. But the fix is trivial, so there's no reason to take the risk. crypto.timingSafeEqual compares both buffers in constant time, regardless of where the mismatch occurs. Use it everywhere.
Rotating Secrets Without Downtime
Webhook secrets should be rotated periodically, and you need a plan for doing it without dropping valid webhooks. The strategy is simple: during the rotation window, accept signatures from both the old and new secret. Once all in-flight webhooks signed with the old secret have been delivered (give it 24-48 hours), remove the old secret.
import crypto from 'crypto';
function verifyWithRotation(
rawBody: string,
signatureHeader: string,
secrets: string[]
): boolean {
for (const secret of secrets) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
)) {
return true;
}
}
return false;
}
// During rotation, pass both secrets
const ACTIVE_SECRETS = [
process.env.WEBHOOK_SECRET_NEW!,
process.env.WEBHOOK_SECRET_OLD!,
];
const isValid = verifyWithRotation(rawBody, signature, ACTIVE_SECRETS);
A common mistake during rotation is updating the secret on the provider side before deploying the code that accepts the new secret. Always deploy your code first, verify it accepts both secrets, then update the provider. The sequence matters: code change, then provider change, then remove old secret from code.
IP Allowlisting as a Second Layer
Some providers publish the IP addresses their webhooks originate from. Stripe publishes theirs in their documentation, and GitHub provides an API endpoint at /meta that lists their webhook IPs. You can use this as an additional layer of defense.
I want to be clear: IP allowlisting is not a replacement for signature verification. IPs can be spoofed, cloud providers recycle IP addresses, and providers occasionally add new IPs without much notice. If you rely solely on IP allowlisting and a provider adds a new IP range, you'll silently drop valid webhooks until someone notices.
That said, as a defense-in-depth measure, IP allowlisting is worth the effort for high-value endpoints. Reject requests from unknown IPs before even attempting signature verification. It reduces noise and raises the bar for attackers. Just make sure you're checking the right IP. Behind a load balancer or CDN, the client address lives in the X-Forwarded-For header while the socket only knows about the proxy.
Mistakes I Keep Seeing
After reviewing dozens of webhook integrations, the same mistakes come up repeatedly. Here are the ones that cause the most damage.
Not Verifying At All
This one is surprisingly common. A developer sets up a webhook handler during prototyping, skips verification because "I'll add it later," and the code ships to production unchanged. Months go by. The handler processes tens of thousands of events. Nobody adds verification because the handler "works fine." Then one day someone fuzzes the endpoint and discovers it accepts anything.
Verifying After Parsing
The signature must be computed against the raw bytes of the request body, exactly as they arrived over the wire. If you parse the JSON first and then try to verify, you're comparing against a re-serialized version that may not match. In Express.js, this means using express.raw() or express.text() for your webhook route, not express.json(). In Next.js App Router, read the body with await request.text() before doing anything else.
Logging the Secret
"Debug logging" that dumps all environment variables or all headers to your log aggregator. I've seen webhook secrets sitting in Datadog logs, accessible to anyone on the engineering team. Treat webhook secrets with the same care as database passwords. Mask them in logs, rotate them if they're ever exposed, and store them in your secrets manager, not in plaintext config files.
Using String Comparison
Already covered above, but worth repeating because it's everywhere. A quick search through open source webhook handlers reveals a startling number that use === or even == for signature comparison. Use crypto.timingSafeEqual. Always.
Verify First, Process Second
Signature verification is fiddly rather than hard. Every provider here uses HMAC-SHA256 with a shared secret. What differs is the encoding, the header name, and exactly which bytes get signed. Read those three things out of the provider's documentation before you write any code and you have skipped most of the debugging.
Verify first, process second. A handler that rejects unverified payloads before it touches business logic costs you a few lines. Skipping it gives you an endpoint that trusts anyone who knows the URL.
Frequently asked questions
Why does signature comparison need a timing-safe function?
Because the ordinary equality operator returns as soon as two bytes differ, and the time it took to return tells an attacker how many leading bytes were correct. Repeated with enough samples, that leak reconstructs a valid signature byte by byte. A constant-time comparison always inspects the full length, so the duration carries no information about how close the guess was.
Why must verification happen before the body is parsed?
The signature covers the exact bytes that were transmitted. Parsing to an object and re-serialising produces different whitespace and key ordering, so the hash you compute is over different input than the one the sender signed, and it will never match. Capture the raw buffer on the webhook route, verify against it, and parse afterwards.
How do I rotate a webhook secret without dropping deliveries?
Run both secrets at once for a window longer than the provider's retry period. Verify against the current secret, fall back to the previous one, and record which matched so you can watch traffic on the old secret fall to zero before removing it. A single-step swap fails everything already in flight, including retries of events that were signed before the change.
Is IP allowlisting a substitute for signature verification?
No, only a second layer. Provider IP ranges change, sometimes without much notice, and anything sharing that infrastructure can originate traffic from inside the allowlist. It usefully cuts off untargeted internet noise before your handler runs, but the signature is the thing that actually proves who sent the request.