Accepting inbound webhooks from Stripe, GitHub, Slack, Shopify, or a partner ERP looks deceptively simple in a prototype: expose a POST /webhooks/provider endpoint, parse the JSON body, and update the database. In production, an unverified or naively verified webhook endpoint is an open backdoor allowing anyone who discovers the URL to forge payment_intent.succeeded events, replay captured deliveries, or trigger timing side-channel leaks.
While building the developer security tools on PixelValut Tools, I have debugged webhook signature failures across dozens of Node.js, Laravel, and Go codebases. Almost every bug comes down to one of four mistakes: computing the HMAC over re-serialized JSON instead of the raw request bytes, comparing digests with === instead of a constant-time equality check, ignoring the timestamp header so captured payloads remain replayable forever, or mixing up hex vs. Base64 digest encodings.
📚 Series Roadmap — Browser-Side Security & Zero-Trust Forensics
- Part 1: Deobfuscating Malicious npm & Browser Payloads: Base64, Hex, Unicode Escapes & Homoglyphs
- Part 2 (This Post): Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection
- Part 3: OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
- Part 4: Regular Expression Denial of Service (ReDoS) in JS & PHP: Spotting Catastrophic Backtracking
- Part 5: Subdomain Takeover & Dangling DNS Reconnaissance: Auditing CNAME, NS, TXT & TLS Certificates
- Part 6: Zero-Trust Reverse Proxy Hardening in Nginx: Rate Limiting, Request Smuggling Guards & mTLS
How do you securely verify webhook HMAC-SHA256 signatures and prevent replay attacks?
Direct answer: Read the exact raw HTTP request body bytes before any JSON middleware parses them, concatenate the provider's delivery timestamp with the raw body, compute an HMAC-SHA256 digest using your endpoint secret, compare digests with crypto.timingSafeEqual(), reject timestamps outside a 300-second window, and deduplicate event IDs in Redis or PostgreSQL.
Why webhook signature checks fail in real codebases (The raw-body trap)
The single most common webhook bug happens when an Express, Next.js, or middleware pipeline parses req.body into a JavaScript object, and the developer then runs JSON.stringify(req.body) to feed the HMAC verifier.
Cryptographic hash functions operate on exact byte sequences. If the webhook sender transmitted:
POST /webhooks/orders HTTP/1.1
X-Webhook-Timestamp: 1761588000
X-Webhook-Signature: sha256=8f4e2a91c03b7d...
{"event": "order.paid", "amount": 4900}
Notice the spaces after the colons. When JSON.stringify(req.body) re-serializes that object without spaces ({"event":"order.paid","amount":4900}), or changes key ordering or Unicode escaping, the resulting HMAC-SHA256 digest is completely different from the provider's signature.
You can test this exact byte sensitivity right now in your browser using the PixelValut HMAC Generator: paste {"event": "order.paid"} vs {"event":"order.paid"} with the same secret key and watch the SHA-256 hex output change completely.
Production Node.js / TypeScript implementation: Raw bytes + Timestamp + Constant-Time Compare + Replay Cache
Here is a complete, zero-dependency Node.js (crypto) verification function that enforces timestamp binding, constant-time byte comparison, and atomic replay deduplication:
import crypto from "node:crypto";
export interface WebhookVerifyOptions {
rawBody: Buffer; // Exact unparsed bytes from the HTTP socket
signatureHeader: string; // e.g., "t=1761588000,v1=5257a869e7ecebeda32..."
secret: string; // Endpoint signing secret (whsec_...)
toleranceSeconds?: number;// Default: 300 seconds (5 minutes)
}
export function verifySignedWebhook({
rawBody,
signatureHeader,
secret,
toleranceSeconds = 300,
}: WebhookVerifyOptions): { valid: boolean; timestamp?: number; reason?: string } {
// 1. Parse timestamp (t=) and v1 signatures from header
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.trim().split("=", 2))
);
const timestamp = Number(parts.t);
const receivedSigHex = parts.v1;
if (!Number.isInteger(timestamp) || !receivedSigHex) {
return { valid: false, reason: "malformed_signature_header" };
}
// 2. Enforce timestamp replay window (always compare in Unix seconds, not ms!)
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
return { valid: false, timestamp, reason: "timestamp_outside_tolerance" };
}
// 3. Compute HMAC-SHA256 over "<timestamp>.<rawBody>"
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, "utf8"),
rawBody,
]);
const expectedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest();
// 4. Decode hex and guard length before crypto.timingSafeEqual
const receivedBuf = Buffer.from(receivedSigHex, "hex");
if (receivedBuf.length !== expectedSig.length) {
return { valid: false, timestamp, reason: "signature_length_mismatch" };
}
const isMatch = crypto.timingSafeEqual(receivedBuf, expectedSig);
return isMatch
? { valid: true, timestamp }
: { valid: false, timestamp, reason: "signature_mismatch" };
}
Look closely at two critical lines in that implementation:
-
Seconds vs. milliseconds in timestamp checks (
Math.floor(Date.now() / 1000)): Stripe, Slack, and most webhook specs send Unix timestamps in seconds (10 digits), whereas JavaScriptDate.now()returns milliseconds (13 digits). Comparing them directly causes every request to fail the drift check or appear to come from 1970—a classic bug we covered in Why Does My Date Show 1970? and easy to inspect in the Unix Timestamp Converter. -
Length guard before
crypto.timingSafeEqual(): In Node.js, callingcrypto.timingSafeEqual(a, b)whena.length !== b.lengththrows a synchronousRangeError(Input buffers must have the same byte length), turning a malformed header into an unhandled500 Internal Server Errorif not guarded.
How Stripe, GitHub, Slack, and Shopify Format Webhook Signatures
Different providers sign slightly different canonical strings and encode the digest in either hexadecimal or Base64. Use this reference table when wiring or debugging a provider integration:
| Provider | Signature Header Name | Canonical Signed String Format | Digest Algorithm & Encoding | Replay Protection Mechanism |
|---|---|---|---|---|
| Stripe | Stripe-Signature |
<timestamp>.<raw_body> (from t= prefix) |
HMAC-SHA256 (Hex in v1=) |
300s timestamp tolerance + evt_... unique ID deduplication |
| GitHub | X-Hub-Signature-256 |
<raw_body> (body bytes only) |
HMAC-SHA256 (Hex prefixed with sha256=) |
Deduplicate X-GitHub-Delivery UUID header |
| Slack | X-Slack-Signature |
v0:<X-Slack-Request-Timestamp>:<raw_body> |
HMAC-SHA256 (Hex prefixed with v0=) |
300s check on X-Slack-Request-Timestamp
|
| Shopify | X-Shopify-Hmac-Sha256 |
<raw_body> (body bytes only) |
HMAC-SHA256 (Base64 encoded) |
Deduplicate X-Shopify-Webhook-Id header |
| Standard Webhooks / Svix | webhook-signature |
<webhook-id>.<webhook-timestamp>.<raw_body> |
HMAC-SHA256 (Base64 prefixed with v1,) |
300s check on webhook-timestamp + webhook-id
|
Notice how Shopify and Standard Webhooks encode the HMAC digest in Base64, while Stripe, GitHub, and Slack use lowercase hex. When testing custom webhook signatures locally, you can generate both hex and Base64 HMAC digests in the HMAC Generator and send test requests using the PixelValut API Tester.
Why a 5-minute timestamp window still needs an event-ID replay cache
Binding the timestamp inside the HMAC signature (<timestamp>.<raw_body>) stops an attacker from capturing a webhook today and replaying it next week—because changing the timestamp header invalidates the HMAC, and keeping the old timestamp trips the 300-second drift check.
However, within that 300-second window, a captured request (or a legitimate provider retry when your server times out) is still cryptographically valid. To prevent double-crediting an account or firing duplicate downstream actions, always combine HMAC verification with an atomic idempotency check:
-- Record the provider's unique event_id inside the same transaction as your state change
INSERT INTO processed_webhook_events (provider, event_id, received_at)
VALUES ('stripe', 'evt_3Q9xLm2eZvKYlo2C1abc9876', NOW())
ON CONFLICT (provider, event_id) DO NOTHING;
For the full downstream queue, dead-letter, and transactional outbox architecture once the webhook passes verification, see my DEV guides on Resilient ERP & CRM API Integrations: Retries, DLQs, Replay and Multi-Vendor Marketplace Payments: Ledgers & Payouts. And just as we saw in JSON Web Token (JWT) Security Pitfalls, never share the same HMAC secret across staging and production environments.
The 7-step production webhook security checklist
-
Capture raw request bytes before body-parsing middleware. Use
express.raw({ type: 'application/json' })in Express or$request->getContent()in Laravel—neverJSON.stringify(req.body). -
Verify the canonical string against the provider spec. Check whether the provider prepends
<timestamp>.orv0:<timestamp>:and whether the signature is hex or Base64 using the HMAC Generator. -
Use constant-time comparison (
crypto.timingSafeEqualorhash_equals). Guard buffer lengths first so malformed headers return401 Unauthorizedinstead of throwing a500exception. - Enforce a strict ±300s timestamp window. Convert timestamps in the Unix Timestamp Converter to confirm your server compares Unix seconds against Unix seconds (with NTP synchronized).
-
Support dual-secret verification during key rotation. Allow your verifier to check
[current_secret, previous_secret]so you can rotate webhook secrets with zero dropped deliveries. -
Deduplicate
event_idatomically. Store each provider delivery ID in Redis (SET key 1 NX EX 86400) or a unique database constraint before executing business logic. -
Acknowledge fast (
200 OKin <500ms) and process asynchronously. Verify the HMAC, persist the raw event to your database or queue, and return200 OKimmediately so slow downstream work never triggers retry storms.
Frequently asked questions
Why can't I just check the sender's IP address instead of verifying HMAC signatures?
Cloud webhook senders run on dynamic cloud egress pools (often AWS or GCP) shared across customers or subject to change without notice. Worse, if an attacker compromises another tenant on the same platform and points a webhook at your URL, the request arrives from the provider's genuine IP range—only your endpoint-specific HMAC secret proves the event belongs to your account.
Why is === unsafe for comparing HMAC signature strings?
Standard string equality (=== or ==) exits on the first mismatched byte. While network jitter makes remote timing attacks over the internet noisy, constant-time comparison (crypto.timingSafeEqual in Node.js, hash_equals in PHP, hmac.compare_digest in Python) takes three lines of code and eliminates timing side-channels completely.
How do I rotate a compromised webhook secret without losing in-flight events?
Generate a second signing secret in the provider dashboard (Stripe and Svix support multiple active secrets per endpoint, sending multiple v1= values in the header). Update your verifier to accept a match against either the primary or secondary secret, deploy, and then revoke the old secret.
What HTTP status code should my webhook endpoint return when HMAC verification fails?
Return 401 Unauthorized or 400 Bad Request immediately and log the failure metadata (source IP, timestamp header, and missing/mismatched signature reason) without logging the secret itself.
Should I verify webhook signatures on localhost during development?
Yes—keep signature verification active in every environment by setting a test signing secret in .env.testing and generating valid test signatures with the provider CLI (stripe listen) or the PixelValut HMAC Generator. Disabling verification in dev leads to if (env !== 'production') bypasses accidentally shipping to staging or production.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal

Top comments (0)