Webhook Security: Verifying Signatures and Preventing Replay Attacks
Webhooks have an inherent trust problem. When your server receives an HTTP POST claiming to be from Stripe, GitHub, or any other service, how do you know it's legitimate? The endpoint URL isn't secret — anyone who discovers it (through logs, source code, or brute force) can send fake events.
This post covers the two essential security measures every webhook integration needs: signature verification to prove authenticity, and timestamp validation to prevent replay attacks. We'll implement both from scratch in Node.js and Python, and cover common mistakes that leave systems vulnerable.
The Threat Model
Before writing any code, it helps to understand what you're defending against.
Spoofed webhooks are the primary threat. An attacker sends a fabricated webhook to your endpoint — maybe a fake "payment completed" event that causes your system to provision a service without actual payment. If your endpoint processes any incoming POST without verification, this is trivially exploitable.
Replay attacks are subtler. An attacker intercepts a legitimate webhook (through network sniffing, compromised logs, or a breached intermediary) and re-sends it later. The payload and signature are valid because they were originally genuine. Without timestamp validation, your system processes it again.
Man-in-the-middle modification is where an attacker intercepts a webhook in transit and modifies the payload before forwarding it. Signature verification catches this because the modified payload won't match the original signature.
Endpoint discovery is a prerequisite for most attacks. Attackers find your webhook URLs through leaked environment variables, public source code, error messages, or by guessing common paths like /webhooks or /api/hooks. This is why you should use unpredictable URLs in addition to signature verification — defense in depth.
How Webhook Signatures Work
The standard approach is HMAC (Hash-based Message Authentication Code) using SHA-256. Here's the flow:
- You and the webhook provider share a secret key (generated once during setup)
- The provider computes an HMAC-SHA256 hash of the request body using the shared secret
- The hash is sent as a header alongside the webhook (typically
X-Webhook-Signatureor similar) - Your server computes the same hash using the same secret and the received body
- If the hashes match, the webhook is authentic
The critical property of HMAC is that you can't produce a valid signature without knowing the secret. Even if an attacker knows the algorithm and can see the payload, they can't forge the signature.
Implementing Signature Verification
The Sender Side
If you're building a system that sends webhooks, here's how to sign them:
const crypto = require('crypto');
function signWebhookPayload(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
// Sign the timestamp + body together
const signedContent = `${timestamp}.${body}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
return {
signature: `v1=${signature}`,
timestamp,
};
}
// When sending:
function sendWebhook(url, payload, secret) {
const { signature, timestamp } = signWebhookPayload(payload, secret);
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': timestamp,
'X-Webhook-ID': generateEventId(),
},
body: JSON.stringify(payload),
});
}
import hmac
import hashlib
import json
import time
import requests
def sign_webhook_payload(payload: dict, secret: str) -> tuple[str, str]:
timestamp = str(int(time.time()))
body = json.dumps(payload, separators=(",", ":"))
signed_content = f"{timestamp}.{body}"
signature = hmac.new(
secret.encode(), signed_content.encode(), hashlib.sha256
).hexdigest()
return f"v1={signature}", timestamp
def send_webhook(url: str, payload: dict, secret: str):
signature, timestamp = sign_webhook_payload(payload, secret)
return requests.post(url, json=payload, headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Webhook-Timestamp": timestamp,
"X-Webhook-ID": generate_event_id(),
})
The v1= prefix is a versioning convention. If you ever need to change your signing algorithm, you can introduce v2= signatures and support both during a migration period.
The Receiver Side
Verification is where most mistakes happen. Here's a correct implementation:
const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!signature || !timestamp) {
return { valid: false, reason: 'Missing signature or timestamp headers' };
}
// Step 1: Check timestamp freshness (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
const webhookTime = parseInt(timestamp, 10);
const tolerance = 300; // 5 minutes
if (isNaN(webhookTime) || Math.abs(now - webhookTime) > tolerance) {
return { valid: false, reason: 'Timestamp outside tolerance window' };
}
// Step 2: Get the raw request body (NOT parsed JSON)
const rawBody = req.rawBody; // see note below on middleware
// Step 3: Compute expected signature
const signedContent = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// Step 4: Constant-time comparison
const actual = signature.replace('v1=', '');
const isValid = crypto.timingSafeEqual(
Buffer.from(actual, 'utf8'),
Buffer.from(expected, 'utf8')
);
return { valid: isValid, reason: isValid ? null : 'Signature mismatch' };
}
import hmac
import hashlib
import time
def verify_webhook_signature(
raw_body: bytes, signature: str, timestamp: str, secret: str
) -> tuple[bool, str]:
if not signature or not timestamp:
return False, "Missing signature or timestamp"
# Step 1: Check timestamp freshness
try:
webhook_time = int(timestamp)
except ValueError:
return False, "Invalid timestamp"
now = int(time.time())
tolerance = 300 # 5 minutes
if abs(now - webhook_time) > tolerance:
return False, "Timestamp outside tolerance window"
# Step 2: Compute expected signature
signed_content = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode(), signed_content.encode(), hashlib.sha256
).hexdigest()
# Step 3: Constant-time comparison
actual = signature.replace("v1=", "")
is_valid = hmac.compare_digest(actual, expected)
return is_valid, None if is_valid else "Signature mismatch"
# Flask example
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
is_valid, reason = verify_webhook_signature(
raw_body=request.get_data(),
signature=request.headers.get("X-Webhook-Signature", ""),
timestamp=request.headers.get("X-Webhook-Timestamp", ""),
secret=WEBHOOK_SECRET,
)
if not is_valid:
return {"error": reason}, 401
event = request.get_json()
process_event(event)
return {"received": True}, 200
Common Mistakes That Break Signature Verification
Mistake 1: Using Parsed JSON Instead of Raw Body
This is the single most common bug. If your web framework parses the JSON body and you re-serialize it for verification, the output may differ from the original: key ordering can change, whitespace might be added or removed, and Unicode escaping may vary.
You must verify against the exact bytes that were sent. In Express.js, you need middleware to capture the raw body:
// Express.js — capture raw body for signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
In FastAPI / Starlette:
@app.post("/webhooks")
async def handle_webhook(request: Request):
raw_body = await request.body() # raw bytes
body = json.loads(raw_body) # parse separately
Mistake 2: String Comparison Instead of Constant-Time Comparison
A regular string comparison (=== or ==) returns false as soon as it finds the first mismatched character. An attacker can measure the response time for different signature guesses and gradually determine the correct signature one character at a time. This is a timing attack.
Always use crypto.timingSafeEqual() in Node.js or hmac.compare_digest() in Python. These functions take the same amount of time regardless of how many characters match.
Mistake 3: Not Validating the Timestamp
If you verify the signature but ignore the timestamp, you're vulnerable to replay attacks. An attacker who captures a valid webhook can resend it days or weeks later and it'll pass verification.
Mistake 4: Hardcoding the Secret in Source Code
Your webhook secret should be in an environment variable or a secrets manager, never in your codebase. If it's in source code and that code is pushed to a public repo (or even a private repo that later gets compromised), every webhook you've ever sent can be forged.
Mistake 5: Using a Weak Secret
The webhook secret should be cryptographically random, at least 32 bytes. Don't use passwords, dictionary words, or short strings.
// Generate a proper webhook secret
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
// => "a3f4b8c9d2e1f0a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4"
import secrets
secret = secrets.token_hex(32)
Preventing Replay Attacks
Timestamp validation stops most replay attacks, but for maximum protection you should also implement idempotency on the receiver side.
Timestamp Validation
The sender includes a Unix timestamp in the signed content. The receiver rejects any webhook where the timestamp is more than 5 minutes from the current time.
Why 5 minutes and not less? Clock skew between servers is real. Two servers can easily be 30–60 seconds apart. Five minutes is generous enough to accommodate clock differences and network delays while still being tight enough to prevent meaningful replay windows.
function isTimestampValid(timestamp, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - parseInt(timestamp, 10));
return diff <= toleranceSeconds;
}
Idempotency Keys
For critical events (payments, account changes), add a second layer: track which webhook IDs you've already processed.
async function processWebhookIdempotent(req) {
const webhookId = req.headers['x-webhook-id'];
if (!webhookId) {
return { status: 400, error: 'Missing webhook ID' };
}
// Atomic check-and-insert
const result = await db.query(
`INSERT INTO processed_webhooks (webhook_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id`,
[webhookId]
);
if (result.rows.length === 0) {
// Already processed — return 200 so sender doesn't retry
return { status: 200, message: 'Already processed' };
}
// First time seeing this — process it
await handleEvent(req.body);
return { status: 200, message: 'Processed' };
}
This catches replays even within the 5-minute timestamp window. It also protects against legitimate duplicate deliveries caused by network issues where the sender retries because it didn't receive your 200 response.
Secret Rotation
Webhook secrets should be rotatable without downtime. The standard approach is to support two active secrets simultaneously during a rotation window:
- Generate a new secret
- Configure the sender to sign with the new secret (or both)
- Update the receiver to accept either secret
- After confirming everything works, remove the old secret
function verifyWithMultipleSecrets(req, secrets) {
for (const secret of secrets) {
const result = verifyWebhookSignature(req, secret);
if (result.valid) return result;
}
return { valid: false, reason: 'No matching secret' };
}
// During rotation
const WEBHOOK_SECRETS = [
process.env.WEBHOOK_SECRET_CURRENT,
process.env.WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
Security Checklist
Before going live with a webhook integration:
- Every incoming webhook is signature-verified before processing
- Verification uses the raw request body, not re-serialized JSON
- Signature comparison is constant-time (timingSafeEqual / compare_digest)
- Timestamps are validated with a 5-minute tolerance
- Webhook secrets are stored in environment variables, not source code
- Secrets are at least 32 bytes of cryptographic randomness
- Critical events use idempotency keys to prevent duplicate processing
- Secret rotation is supported without downtime
- Webhook endpoint URLs are unpredictable (not just
/webhooks) - Failed signature verification returns 401 and logs the attempt
Don't Roll Your Own (Unless You Want To)
Webhook signature verification isn't hard to implement, but it's easy to get wrong in ways that aren't obvious until you're exploited. WebhookStream handles signing, verification, timestamp validation, and secret rotation automatically — every webhook sent through the platform includes cryptographic signatures and replay protection out of the box.
If you do implement it yourself, use the code in this guide as a starting point and run through the security checklist before shipping.