Webhook Signature Verification: What It Is and Why You Need It
Without signature verification, anyone who knows your webhook URL can send fake events. How HMAC-based verification works and how to implement it correctly.
Your webhook endpoint is a public URL that accepts HTTP POST requests and triggers real actions in your application: fulfilling orders, provisioning accounts, sending emails. Without some way to verify that requests actually came from your provider, anyone who knows the URL can fire fake events at it.
Webhook signature verification is how you close that gap.
How HMAC signing works
Most webhook providers use HMAC-SHA256 to sign their payloads. The process works like this:
- The provider generates a signing secret - a long random string shared between you and them
- For each webhook, they compute an HMAC -
HMAC-SHA256(secret, raw_body_bytes)- and include it in a request header - On your side, you compute the same HMAC using your copy of the secret and the raw request body
- If the two values match, the request is authentic - only someone with the secret could have produced that signature
The key properties:
- Forgery-resistant - without the secret, you cannot produce a valid signature
- Tamper-evident - changing even a single byte of the payload invalidates the signature
- Replayable without extra protection - a valid signed request can be captured and re-sent; see preventing replay attacks below
How providers implement it
Each provider has their own flavour. Here is how the main ones work:
Stripe
Header: Stripe-Signature
Format: t=<timestamp>,v1=<signature>
Stripe includes a timestamp in the signature to prevent replay attacks. The signed payload is timestamp.raw_body (not just the body). They provide stripe.webhooks.constructEvent() which handles all of this.
const event = stripe.webhooks.constructEvent(
rawBody, // raw Buffer - must not be parsed first
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
GitHub
Header: X-Hub-Signature-256
Format: sha256=<hex_hmac>
GitHub signs the raw body with your secret. The header value is sha256= followed by the hex-encoded HMAC.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyGitHubSignature(
rawBody: Buffer,
signature: string,
secret: string
): boolean {
if (typeof signature !== 'string' || !/^sha256=[a-fA-F0-9]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(signature.slice(7), 'hex');
return received.length === expected.length && timingSafeEqual(expected, received);
}
Shopify
Header: X-Shopify-Hmac-SHA256
Format: base64-encoded HMAC (not hex)
import { createHmac, timingSafeEqual } from 'crypto';
function verifyShopifySignature(
rawBody: Buffer,
signature: string,
secret: string
): boolean {
if (typeof signature !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(signature, 'base64');
if (received.length !== expected.length || received.toString('base64') !== signature) return false;
return timingSafeEqual(expected, received);
}
Custom HMAC
If you are building your own webhook system or working with a provider that does not have native SDK support, you will typically implement HMAC-SHA256 directly:
import { createHmac, timingSafeEqual } from 'crypto';
function verifyHmacSignature(
rawBody: Buffer,
signature: string,
secret: string
): boolean {
if (typeof signature !== 'string' || !/^[a-fA-F0-9]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(expected, received);
}
The raw body problem
The most common implementation mistake: verifying against a parsed or re-serialised body instead of the original raw bytes.
When your web framework parses a JSON request body, it reads the bytes and converts them to a JavaScript object. If you then JSON-stringify that object to verify the signature, you may get a different byte sequence: different whitespace, different key ordering, different Unicode escape handling.
The signature was computed against the original bytes. You must verify against those same bytes.
In Express:
// Wrong - json() parses the body and loses the raw bytes
app.use(express.json());
app.post('/webhooks', (req, res) => {
// req.body is now a JS object, verification will fail
});
// Right - use raw() for the webhook route specifically
app.post(
'/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
// req.body is a Buffer - raw bytes intact
}
);
In Next.js App Router:
export async function POST(request: Request) {
// Use request.text() or request.arrayBuffer(), not request.json()
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
// Verify against rawBody string
}
In Next.js Pages Router (API routes):
export const config = {
api: {
bodyParser: false, // disable automatic body parsing
},
};
export default async function handler(req, res) {
const rawBody = await getRawBody(req);
// verify against rawBody
}
Preventing replay attacks
A valid signed webhook can be captured by an attacker and re-sent later to trigger the same action again. Idempotent processing limits duplicate effects, but it does not replace authentication or freshness checks. A valid signature alone does not prevent a captured request from being reused.
The standard defence is timestamp validation. Stripe's signature format includes a timestamp; if the timestamp is too old (Stripe recommends rejecting requests older than 5 minutes), you reject the request even if the signature is valid.
import { createHmac, timingSafeEqual } from 'crypto';
// Stripe's constructEvent() handles this automatically
// It rejects events older than 300 seconds by default
// For custom HMAC implementations, include and validate a timestamp:
function verifyWithTimestamp(
rawBody: Buffer,
signature: string,
timestamp: string,
secret: string,
maxAgeSeconds = 300
): boolean {
if (!/^\d{1,12}$/.test(timestamp) || !/^[a-fA-F0-9]{64}$/.test(signature)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > maxAgeSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(expected, received);
}
Never use === or == to compare HMAC signatures. Standard string comparison short-circuits on the first mismatch, leaking timing information an attacker can use to guess valid signatures. Always use crypto.timingSafeEqual().
Always use timingSafeEqual
When comparing signatures, never use === or string comparison. These short-circuit on the first mismatched character, which leaks timing information an attacker can use to guess valid signatures one character at a time.
Use crypto.timingSafeEqual() (Node.js), which always takes the same amount of time regardless of where the strings differ.
What to do when verification fails
When a webhook fails verification, return a 400 and do not process the event. Log the failure with enough context to investigate: the source IP, the header values, the timestamp.
Do not return 200 on a verification failure. Some monitoring tools count non-2xx responses as delivery failures and will retry. Failed verification should be visible, not silently retried.
The implementation is straightforward once you understand the raw body requirement. Get that right and the rest follows naturally.
Webhook Watchtower handles signature verification for Stripe, GitHub, Shopify, and custom HMAC-SHA256 sources. Events are stamped Verified, Failed, or Skipped so you always know whether a request was trusted. The signature verification docs explain what each stamp means and how to debug mismatches.
Verification through a relay
A relay must preserve the original body bytes and supported provider signature headers. A delayed replay can still fail the provider's timestamp tolerance: Watchtower cannot generate a fresh Stripe signature using the provider's original timestamp. Keep provider freshness validation enabled. For intentional historical processing, use a separate endpoint that verifies Watchtower's fresh HMAC and timestamp, require source verification at ingest, and deduplicate provider event IDs. Do not accept an arbitrary old provider signature on a public endpoint.
Frequently asked questions
What is webhook signature verification?
It's the check that confirms a webhook genuinely came from the provider and wasn't forged or tampered with. The provider signs each request with a shared secret, and you recompute that signature on your side and compare. Without it, anyone who learns your webhook URL can send fake events.
How does HMAC webhook verification work?
The provider computes an HMAC-SHA256 hash of the raw request body using a secret only the two of you know, and sends it in a header. You compute the same hash from the exact bytes you received and compare the two with a constant-time comparison. If they match, the request is authentic and unmodified.
Why is my webhook signature verification failing?
Almost always because you're hashing something other than the exact raw body. A JSON body-parser that re-serialises the payload, trailing whitespace, or character-encoding changes all break the signature. The other common causes are using the wrong secret for the environment or letting the timestamp tolerance window expire.
Should I verify webhook signatures?
Yes, on any endpoint that triggers real actions. An unverified webhook URL is an open door: a request that fulfils an order or grants access should never be trusted just because it reached your server. Verification is a few lines of code and closes that hole.
Share this post
Webhook Watchtower
Stop debugging webhooks in logs.
Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.
Try it free →Related posts
Monitoring Webhook Health: The Metrics That Actually Matter
Uptime doesn't tell you whether webhooks are working. A short list of the signals that actually indicate a healthy webhook pipeline, and what each one catches that the others miss.
Alerting on Webhook Failures Without Alert Fatigue
Notify on every failed delivery attempt and you'll train people to ignore the channel. A pattern for alerting on what actually needs a human, and staying quiet about what doesn't.
IP Allowlisting vs Signature Verification: What Actually Secures a Webhook Endpoint
IP allowlisting feels like a solid first line of defence for a webhook endpoint. It isn't, and understanding why makes the case for signature verification much clearer.