Webhooks pass critical transactional events across public network boundaries. Without cryptographic signature verification, any internet user who discovers your webhook endpoint URL can send forged HTTP requests, potentially triggering unauthorized financial credits, fake database mutations, or privilege escalations.
RFC 2104 defines Hash-based Message Authentication Codes (HMAC) as the standard mechanism for verifying both data integrity and data authenticity. However, improper implementation—such as using standard string comparison operators or verifying signatures on deserialized JSON—leaves systems vulnerable to timing attacks and verification failures.
- Compute HMAC signatures strictly against the unmodified raw byte buffer of the HTTP request body.
- Compare cryptographic digests using constant-time comparison (
crypto.timingSafeEqual) to eliminate timing attacks. - Enforce a timestamp freshness window (typically 300 seconds) to prevent replay attacks.
- Support simultaneous active secrets to enable zero-downtime secret rotation.
Comparison of Webhook Signature Implementations
Different third-party providers use different hashing algorithms, header formats, and encoding conventions.
Architectural characteristics of webhook signatures across leading modern APIs.
Anatomy of a Secure Webhook Verification Flow
To achieve enterprise-grade security, signature verification must adhere to a strict sequence:
- Extract Headers: Retrieve signature and timestamp headers. Reject requests missing these headers with HTTP 401.
- Verify Freshness: Compare header timestamp against
Date.now(). If the difference exceeds tolerance (e.g. 300 seconds), reject with HTTP 403. - Preserve Raw Bytes: Read raw request body directly from the stream before parsing JSON.
- Compute Cryptographic Hash: Compute
HMAC-SHA256(secret, timestamp + "." + rawBody). - Constant-Time Comparison: Compare expected digest against incoming header using
timingSafeEqual. - Idempotency Check: Check database for duplicate event IDs.
- Process Body: Parse JSON and execute application logic.
Preventing Timing Attacks with timingSafeEqual
Standard equality operators in JavaScript (=== or ==) compare strings character-by-character and terminate evaluation upon encountering the first non-matching byte.
In statistical network telemetry, an attacker can send thousands of forged requests and measure microsecond response time differences to iteratively guess the signature one byte at a time. Using crypto.timingSafeEqual enforces uniform execution time regardless of where mismatches occur.
// utils/webhook-security.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export interface VerifyWebhookOptions {
rawBody: string;
signatureHeader: string;
secret: string;
timestampHeader?: string | null;
toleranceSeconds?: number;
}
/**
* Validates an HMAC-SHA256 webhook signature with constant-time equality
* and replay-window validation.
*/
export function verifyHmacSignature({
rawBody,
signatureHeader,
secret,
timestampHeader,
toleranceSeconds = 300,
}: VerifyWebhookOptions): boolean {
// 1. Replay attack validation
if (timestampHeader) {
const eventTime = Number.parseInt(timestampHeader, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (Number.isNaN(eventTime) || Math.abs(currentTime - eventTime) > toleranceSeconds) {
console.warn(`Webhook timestamp out of tolerance: ${eventTime}`);
return false;
}
}
// 2. Prepare payload to hash
const payloadToSign = timestampHeader ? `${timestampHeader}.${rawBody}` : rawBody;
// 3. Compute expected signature
const expectedSignatureHex = createHmac('sha256', secret)
.update(payloadToSign, 'utf8')
.digest('hex');
// Normalize signature from header (strip prefixes like sha256= or v1=)
const cleanSignature = signatureHeader.replace(/^(sha256=|v1=)/, '').trim();
// 4. Constant-time buffer comparison
const expectedBuffer = Buffer.from(expectedSignatureHex, 'utf8');
const providedBuffer = Buffer.from(cleanSignature, 'utf8');
// Buffers must have identical length for timingSafeEqual
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer);
}Zero-Downtime Secret Key Rotation
Signing secrets must be rotated periodically or immediately after employee turnover. If your verification code only checks a single secret, swapping secrets in production will reject all in-flight webhook deliveries.
Implement multi-secret verification:
export function verifyWithRotation(
payload: VerifyWebhookOptions,
activeSecrets: string[]
): boolean {
// Test candidate secrets until one matches
for (const secret of activeSecrets) {
const isValid = verifyHmacSignature({ ...payload, secret });
if (isValid) {
return true;
}
}
return false;
}During rotation:
- Generate new secret key
SECRET_NEW. - Deploy backend with
activeSecrets = [SECRET_CURRENT, SECRET_NEW]. - Update secret in provider dashboard.
- Verify deliveries succeed using
SECRET_NEW. - Remove
SECRET_CURRENTfrom backend configuration.
Verifying Signatures During Local Development with Probe
When integrating webhooks from complex third-party platforms, verifying raw HMAC signatures can be difficult without an inspection proxy.
With Probe, you can:
- View raw headers and unformatted payload buffers side-by-side.
- Inspect incoming
X-SignatureorStripe-Signaturevalues. - Verify whether whitespace changes or Unicode encodings occurred.
- Copy verified payloads into your automated CI test pipeline.
