meworks that automatically parse JSON (e.g., express.json()) modify the payload structure, breaking the signature. The architecture must capture the raw buffer before parsing.
- Asynchronous Processing: The webhook endpoint must return a
200 OK or 202 Accepted immediately after verification. Business logic should be offloaded to a message queue. This prevents timeout errors and mitigates resource exhaustion attacks.
- Idempotency: Webhook providers often retry delivery on failure. The consumer must handle duplicate events without side effects. Idempotency keys derived from the event ID must be checked before processing.
2. Step-by-Step Implementation
Step A: Raw Body Middleware
Configure the server to capture the raw body for the webhook route while parsing JSON for other routes.
import express from 'express';
import crypto from 'crypto';
const app = express();
// Capture raw body for verification
app.post(
'/webhooks/provider',
express.raw({ type: 'application/json', limit: '1mb' }),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.body instanceof Buffer) {
req.rawBody = req.body;
}
next();
}
);
// Parse JSON after raw capture
app.post('/webhooks/provider', express.json(), webhookHandler);
Step B: Verification Logic
Implement a verification function that checks the signature, timestamp, and performs a timing-safe comparison.
interface WebhookConfig {
secret: string;
toleranceMs: number; // Max age of event in milliseconds
algorithm: string;
}
const verifyWebhookSignature = (
payload: Buffer,
signatureHeader: string,
config: WebhookConfig
): boolean => {
// 1. Compute expected signature
const expectedSignature = crypto
.createHmac(config.algorithm, config.secret)
.update(payload)
.digest('hex');
// 2. Timing-safe comparison to prevent timing attacks
const sigBuffer = Buffer.from(signatureHeader, 'utf8');
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
const isSignatureValid = crypto.timingSafeEqual(sigBuffer, expectedBuffer);
if (!isSignatureValid) {
return false;
}
// 3. Replay attack mitigation via timestamp
// Assuming timestamp is sent in header 'X-Webhook-Timestamp'
const timestampHeader = signatureHeader.split(',')[0]; // Adjust based on provider format
const timestamp = parseInt(timestampHeader, 10);
const now = Date.now();
if (Math.abs(now - timestamp) > config.toleranceMs) {
return false; // Event too old or too far in future
}
return true;
};
Step C: Handler and Queue Dispatch
The handler verifies the payload, checks idempotency, and dispatches to a queue.
const webhookHandler = async (req: express.Request, res: express.Response) => {
const signature = req.headers['x-webhook-signature'] as string;
const config: WebhookConfig = {
secret: process.env.WEBHOOK_SECRET!,
toleranceMs: 300_000, // 5 minutes
algorithm: 'sha256'
};
// Verify
if (!verifyWebhookSignature(req.rawBody, signature, config)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
const event = req.body;
const eventId = event.id;
// Idempotency Check
const isProcessed = await idempotencyStore.check(eventId);
if (isProcessed) {
res.status(200).json({ status: 'duplicate' });
return;
}
// Mark as processing to prevent race conditions
await idempotencyStore.lock(eventId);
// Dispatch to Queue
await messageQueue.push({
eventId,
type: event.type,
payload: event.data
});
res.status(202).json({ status: 'accepted' });
};
3. Secret Management
Webhook secrets must never be hardcoded. Use a secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault) to rotate secrets dynamically. Implement a dual-secret strategy during rotation: verify against the current secret, and if that fails, attempt verification against the previous secret for a grace period. This ensures zero downtime during rotation.
Pitfall Guide
1. JSON Parsing Before Verification
Mistake: Using express.json() middleware before signature verification.
Impact: The framework may reorder keys, normalize whitespace, or convert number types, altering the byte sequence. The HMAC will fail even for valid events.
Remediation: Always capture req.rawBody as a buffer before any JSON parsing middleware runs.
2. Timing Attacks on Signature Comparison
Mistake: Using === or == to compare signature strings.
Impact: String comparison returns early on the first mismatch. An attacker can measure response times to deduce the correct signature byte-by-byte.
Remediation: Use crypto.timingSafeEqual which executes in constant time regardless of where the mismatch occurs.
3. Ignoring Replay Attacks
Mistake: Verifying the signature but not checking the event timestamp.
Impact: An attacker intercepts a valid event and replays it infinitely. This can trigger duplicate charges, notifications, or state changes.
Remediation: Enforce a strict timestamp window (e.g., Β±5 minutes) and reject events outside this window.
4. Synchronous Processing
Mistake: Executing business logic (database writes, external API calls) inside the webhook handler.
Impact: If the provider's timeout is 5 seconds and your logic takes 6, the provider retries. Your system processes the event twice, or the provider marks your endpoint as unhealthy.
Remediation: Return 202 Accepted immediately after verification and push work to a background queue.
5. Secret Leakage in Logs
Mistake: Logging the raw payload or headers for debugging.
Impact: Webhook secrets or sensitive PII in payloads may be written to log aggregation systems, violating compliance and exposing secrets.
Remediation: Implement log sanitization. Never log req.rawBody or signature headers. Use structured logging with explicit allowlists of safe fields.
6. Lack of Idempotency
Mistake: Assuming each event arrives exactly once.
Impact: Network glitches cause retries. Without idempotency, duplicate events cause data corruption or duplicate actions.
Remediation: Maintain an idempotency store (e.g., Redis with TTL) keyed by event.id. Check existence before processing.
7. Trusting Event Types Blindly
Mistake: Routing logic based solely on event.type without validating the payload structure.
Impact: An attacker could send a malicious payload with a spoofed type, triggering unexpected code paths or logic bombs.
Remediation: Validate the payload schema against the expected structure for the declared event type.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public Third-Party Webhook | HMAC + Timestamp + Idempotency | Balances security with provider compatibility. mTLS is rarely supported by public SaaS. | Low |
| Internal Microservice Events | mTLS or Internal Network + HMAC | mTLS provides mutual authentication and encryption. Internal network reduces exposure. | Medium |
| High-Value Financial Transactions | HMAC + Timestamp + Strict Schema + Replay Store | Requires maximum assurance against replay and tampering. Replay store tracks all nonces. | Medium |
| Development / Staging | HMAC + Timestamp | Maintains security posture even in non-prod to catch integration bugs early. | Low |
Configuration Template
// webhook.config.ts
export const webhookConfig = {
endpoint: '/api/v1/webhooks',
secretEnvKey: 'WEBHOOK_HMAC_SECRET',
toleranceMs: 300_000, // 5 minutes
algorithm: 'sha256',
retryLimit: 3,
queueName: 'webhook-events',
idempotencyTtl: 86_400, // 24 hours
maxPayloadSize: '1mb',
headers: {
signature: 'x-webhook-signature',
timestamp: 'x-webhook-timestamp',
eventId: 'x-webhook-event-id'
}
};
// middleware/verifyWebhook.ts
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { webhookConfig } from '../config/webhook.config';
export const verifyWebhook = (req: Request, res: Response, next: NextFunction) => {
const signature = req.headers[webhookConfig.headers.signature] as string;
const timestamp = req.headers[webhookConfig.headers.timestamp] as string;
const secret = process.env[webhookConfig.secretEnvKey];
if (!signature || !timestamp || !secret) {
return res.status(400).json({ error: 'Missing required headers' });
}
// Replay check
const eventTime = parseInt(timestamp, 10);
if (Math.abs(Date.now() - eventTime) > webhookConfig.toleranceMs) {
return res.status(400).json({ error: 'Event timestamp out of range' });
}
// Verification
const rawBody = req.rawBody as Buffer;
const expectedSig = crypto
.createHmac(webhookConfig.algorithm, secret)
.update(rawBody)
.digest('hex');
const sigBuffer = Buffer.from(signature, 'utf8');
const expBuffer = Buffer.from(expectedSig, 'utf8');
if (sigBuffer.length !== expBuffer.length || !crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
Quick Start Guide
- Install Dependencies:
npm install express crypto
- Add Raw Body Middleware:
Insert
express.raw({ type: 'application/json' }) before your JSON parser on the webhook route to capture req.rawBody.
- Implement Verification:
Copy the
verifyWebhook middleware from the template. Ensure you set the WEBHOOK_HMAC_SECRET environment variable.
- Test with Curl:
Generate a test signature locally and send a request to verify the middleware rejects invalid signatures and accepts valid ones within the timestamp window.
# Generate signature
SIGNATURE=$(echo -n '{"id":"123"}' | openssl dgst -sha256 -hmac "my_secret" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIGNATURE" \
-H "x-webhook-timestamp: $(date +%s)000" \
-d '{"id":"123"}'