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 acts as a key that makes the hash unique to your account -- without it, an attacker can't produce a valid signature even if they know the 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 -- whitespace
changes, key ordering, 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 -- you'll need to 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 these tiny time differences -- we're talking nanoseconds
to microseconds -- and gradually 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 --
if you're behind a load balancer or CDN, the client IP will be in the X-Forwarded-For
header, not the socket's remote address.
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.
Putting It All Together
Webhook signature verification is not complicated, but it demands attention to detail. The algorithm is the same across providers -- HMAC-SHA256 with a shared secret -- but the encoding, header names, and signed content vary. Get the raw body before parsing, use timing-safe comparison, rotate secrets with a transition window, and consider IP allowlisting for high-stakes endpoints.
If you take one thing from this guide: verify first, process second. Every webhook handler should reject unverified payloads before touching business logic. The cost of adding verification is a few lines of code. The cost of skipping it is an endpoint that trusts anyone who knows the URL.