Securing Webhooks with HMAC-SHA256 Signatures & Replay Windows
The Public Endpoint Problem: Why Obscurity Fails Webhooks
Every software engineering team integrating modern SaaS services—whether processing payments via Stripe, receiving git push events from GitHub, or tracking shipments via logistics providers—must expose an HTTP webhook receiver:
POST https://api.yourcompany.com/api/v1/webhooks/payment-completed
To allow external third-party servers to push real-time event notifications to your system, this endpoint must be publicly accessible on the open Internet without traditional user session cookies.
Here lies the dangerous security misconception: many developers assume that because the webhook URL contains a random token or is kept out of public documentation, nobody will ever find it.
In reality, URLs leak constantly through server access logs, reverse proxy error traces, browser referrer headers, and automated API discovery scanners. If an attacker discovers your endpoint, they can use curl to fire a forged payload:
# An attacker sends forged data to your billing webhook:
curl -X POST https://api.yourcompany.com/api/v1/webhooks/payment-completed \
-H "Content-Type: application/json" \
-d '{"event":"charge.succeeded","order_id":9021,"amount":99900,"customer":"attacker@evil.com"}'
Without cryptographic verification, your application parses the JSON, trusts the payload, updates the database, and marks an expensive order as paid without receiving a single real cent!
FORGED WEBHOOK INJECTION: THE NAIVE RECEIVER TRAP
- Attacker Laptop (curl)
- Your Webhook Endpoint
- POST /api/webhooks/payment
- Payload: {“status”:“PAID”, “amount”:5000} — (Parses JSON blindly) (Updates PostgreSQL)
- 200 OK (Order Delivered to Attacker!)
- Your business ships physical merchandise or provisions SaaS without payment!
To protect mission-critical web apps according to the guidelines of OWASP, all webhook receivers must implement HMAC-SHA256 signature verification paired with timestamp tolerance windows.
The Cryptographic Solution: HMAC-SHA256 Signatures
The industry standard for authenticating webhooks—adopted by Stripe, GitHub, Shopify, and Twilio—is Hash-based Message Authentication Code (HMAC) using the SHA-256 cryptographic digest.
The sender and receiver establish a shared secret signing secret (whsec_...). When an event occurs:
- The sender captures the current UNIX timestamp (e.g.,
t=1730000000). - The sender concatenates the timestamp with the exact raw byte payload:
t=1730000000.{"event":"invoice.paid"}. - The sender computes the HMAC-SHA256 hash using the shared secret.
- The sender passes the timestamp and computed signature in an HTTP header:
Stripe-Signature: t=1730000000,v1=9f8e7d6c5b4a3928170e1f2a3b4c5d6e...
HMAC-SHA256 SIGNATURE GENERATION & VERIFICATION
- SENDER (e.g. Stripe Gateway): [Timestamp (t)] + “.” + [Raw Request Body Bytes]
- HMAC-SHA256(data, SHARED_WEBHOOK_SECRET) → Signature: “a9f8e…”
- Transmits HTTP POST with Header: X-Signature: t=173000,v1=a9f8e…
- RECEIVER (Your Application): 1. Extracts timestamp ‘t’ and claimed signature ‘v1’ from header.
- Re-computes: HMAC-SHA256(t + “.” + RAW_RECEIVED_BYTES, SHARED_SECRET).
- Constant-Time Equality Check: Expected Signature == Claimed Signature?
- MATCH: Process webhook with 100% mathematical certainty!
- MISMATCH: Immediate 400 Bad Request! Discard malicious payload!
Because an attacker outside the trusted gateway does not possess the shared secret, forging a valid signature is mathematically impossible.
The Raw Body Catch: Why JSON Parsers Break Signature Verification
The single most common bug when implementing webhook signature verification in Node.js, Python, or Go is using a parsed JSON object instead of raw buffer bytes.
Consider standard Express or Fastify middleware:
// The classic developer mistake:
app.use(express.json()); // Parses bytes into a JavaScript object!
app.post('/webhook', (req, res) => {
// Re-stringifying the parsed object does NOT recreate original bytes!
const reconstructedPayload = JSON.stringify(req.body);
const signature = computeHmac(reconstructedPayload); // SIGNATURE FAILS!
});
Why does this fail?
- JSON serialization does not guarantee key ordering:
{"a":1,"b":2}becomes{"b":2,"a":1}. - Whitespace and line breaks are discarded:
{\n "id": 1\n}becomes{"id":1}. - Large integers or floating-point floats may undergo subtle rounding.
To compute the correct HMAC, you must verify the exact, unparsed binary byte stream received across the network socket.
Production Implementation in Node.js & Fastify
Here is a hardened, production-ready webhook verification implementation using Fastify and Node’s native crypto library:
// src/routes/webhookReceiver.ts
import { FastifyPluginAsync } from 'fastify';
import crypto from 'node:crypto';
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
const MAX_TOLERANCE_SECONDS = 300; // 5-minute replay window
export const webhookReceiver: FastifyPluginAsync = async (app) => {
// Instruct Fastify to preserve raw binary buffers for this route
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
done(null, body);
}
);
app.post('/api/v1/webhooks/stripe', async (req, reply) => {
const rawBodyBuffer = req.body as Buffer;
const signatureHeader = req.headers['stripe-signature'] as string;
if (!signatureHeader || !rawBodyBuffer) {
return reply.status(400).send({ error: 'Missing signature or payload.' });
}
// Step 1: Parse signature header (format: t=timestamp,v1=signature)
const elements = signatureHeader.split(',');
const timestampItem = elements.find((el) => el.startsWith('t='));
const signatureItem = elements.find((el) => el.startsWith('v1='));
if (!timestampItem || !signatureItem) {
return reply.status(400).send({ error: 'Malformed signature header.' });
}
const timestamp = parseInt(timestampItem.split('=')[1], 10);
const claimedSignature = signatureItem.split('=')[1];
// Step 2: Prevent Replay Attacks (Verify Timestamp Window)
const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - timestamp) > MAX_TOLERANCE_SECONDS) {
return reply.status(400).send({ error: 'Timestamp outside tolerance window.' });
}
// Step 3: Compute Expected HMAC Signature over: timestamp + "." + rawBody
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(`${timestamp}.`);
hmac.update(rawBodyBuffer);
const expectedSignature = hmac.digest('hex');
// Step 4: Constant-Time Comparison (Neutralizes Timing Attacks)
const claimedBuffer = Buffer.from(claimedSignature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (
claimedBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(claimedBuffer, expectedBuffer)
) {
return reply.status(400).send({ error: 'Cryptographic signature mismatch.' });
}
// Step 5: Safely parse JSON after cryptographic verification succeeds
const verifiedEvent = JSON.parse(rawBodyBuffer.toString('utf-8'));
console.log(`[Webhook Verified] Event: ${verifiedEvent.type} ID: ${verifiedEvent.id}`);
// Return immediate 200 OK to prevent gateway timeouts
return reply.status(200).send({ received: true });
});
};
Notice the call to crypto.timingSafeEqual(). Standard string equality checks (===) return false on the very first mismatched character, allowing attackers to deduce valid signatures character by character using nanosecond timing attacks. Constant-time comparison ensures total cryptographic immunity.
Neutralizing Replay Attacks with Timestamp Windows
A signature proves that the payload was generated by Stripe, but it does not prove that the message was sent right now.
Suppose an attacker intercepts an encrypted HTTPS session or sniffs network traffic. They cannot modify the payment amount, but they can capture the valid signed webhook and re-post the exact same request 50 times over the next week:
WEBHOOK REPLAY ATTACK: DUPLICATING VALID EVENTS
- Day 1: Stripe sends legitimate refund webhook: POST /webhook (Refund €50) → Application refunds €50.
- (Attacker records exact HTTP payload & header)
- Day 5: Attacker replays captured transaction: POST /webhook (Refund €50) → Re-evaluates valid signature!
- Application refunds €50 AGAIN! Attacker drains your bank account!
To crush replay attacks, our verification algorithm enforces two complementary safeguards:
- The 300-Second Timestamp Window: If
current_time - timestamp > 300, the server drops the request immediately. - Event ID Idempotency: The verified event ID (
evt_3M...) is recorded in PostgreSQL or Redis. If a duplicate arrives within the valid window, the server recognizes it has already been processed and ignores it, respecting the ethos of anti-over-engineering.
Production Best Practices: Acknowledgment Speeds & Signature Rotation
Operating enterprise webhook systems according to the engineering standards of WebXtek requires following three rules:
- Acknowledge in Under 500ms: Third-party gateways enforce strict 5-second connection timeouts. Never run heavy business logic or PDF generation inside the webhook route handler. Verify the signature, push the event into an asynchronous queue (such as BullMQ), and return
200 OKimmediately. - Support Secret Key Rolling: When rotating webhook keys, maintain dual validation support in code. Verify against
CURRENT_WEBHOOK_SECRETfirst; if that fails, attempt verification againstNEXT_WEBHOOK_SECRETbefore rejecting. - Log Raw Failures Securely: Never log the secret key or full auth headers in error dumps. Log only the first six characters of the event ID and the HTTP status code to prevent credentials from leaking into centralized logging aggregators.
By verifying HMAC-SHA256 signatures against raw bytes, enforcing strict timestamp tolerance windows, and deduplicating event IDs, your engineering team can integrate external SaaS platforms with absolute security and zero fear of forged transactions.
In practice, we apply these exact architectural standards across specialized sectors including b2b saas platforms and b2b cybersecurity & corporate intelligence. Whether you are modernizing core workflows through web apps and high-performance websites, connecting sector-specific requirements to disciplined execution is what turns technical theory into compounding commercial advantage.
[+] [ INDEX_DIRECTORY ] 14 topics ▼
- The Public Endpoint Problem: Why Obscurity Fails Webhooks
- The Cryptographic Solution: HMAC-SHA256 Signatures
- The Raw Body Catch: Why JSON Parsers Break Signature Verification
- Production Implementation in Node.js & Fastify
- Neutralizing Replay Attacks with Timestamp Windows
- Production Best Practices: Acknowledgment Speeds & Signature Rotation
- Author
- Frequently Asked Questions
- Why is a public webhook endpoint vulnerable without cryptographic signature verification?
- How does HMAC-SHA256 signature verification work in practice?
- Why must signature verification use the exact unparsed raw byte body?
- What is a webhook replay attack and how does a timestamp tolerance window prevent it?
- Related Nodes
- Path Sequence
Frequently Asked Questions
Why is a public webhook endpoint vulnerable without cryptographic signature verification?
Because webhook endpoints (like /api/v1/webhooks/stripe) must be publicly accessible on the internet to receive third-party callbacks, any malicious actor who discovers the URL can POST forged JSON payloads, falsely marking orders as paid or provisioning unauthorized accounts.
How does HMAC-SHA256 signature verification work in practice?
The sender and receiver share a pre-shared secret key. The sender computes a cryptographic hash (HMAC-SHA256) over the raw request payload and sends it in a header (e.g. Stripe-Signature). The receiver independently computes the hash over the raw bytes and compares them using a constant-time equality check.
Why must signature verification use the exact unparsed raw byte body?
JSON parsers normalize whitespace, reorder object keys, and mutate number precision. If the receiver parses JSON into an object and re-stringifies it before computing the HMAC, the bytes will differ from what the sender transmitted, causing legitimate signatures to fail.
What is a webhook replay attack and how does a timestamp tolerance window prevent it?
In a replay attack, an eavesdropper intercepts a legitimate signed webhook and re-sends the exact same HTTP request later. By prefixing the payload with a UNIX timestamp and rejecting requests older than 300 seconds, the receiver renders replayed captures completely inert.
[ RELATED_NODES ]
> START_PROJECT
Need a website that earns trust, ranks in search, and gives your business a stronger digital presence? Start the conversation here.