Quick Answer: Validate API timestamps by extracting the request timestamp header, comparing it against the current time within a 5-minute tolerance window (the industry standard set by Stripe and Kerberos V5), and rejecting requests outside this window. Combine timestamp validation with HMAC signature verification and nonce-based duplicate detection to prevent replay attacks. Always use constant-time comparison (timingSafeEqualorhmac.compare) to prevent timing attacks.
Your production API just accepted the same payment request twice—from a capture that occurred 6 minutes ago. The timestamp header was valid, the signature checked out, but an attacker replayed the entire request verbatim. This scenario plays out daily across APIs that validate timestamps without implementing proper replay attack defenses. Timestamp validation alone is insufficient; you need the complete defense stack: timestamp windows, nonce tracking, cryptographic signing, and timing-safe comparisons. This article provides production-ready middleware for both Express and FastAPI that you can deploy immediately.
Understanding API Timestamp Validation
Timestamp validation serves as the first line of defense against replay attacks. When a client sends a request, it includes a timestamp (typically in Unix epoch format) in the headers or payload. Your server validates this timestamp falls within an acceptable window, rejecting requests that are too old (stale) or too far in the future (clock skew or malicious manipulation).
The 5-minute tolerance window emerges from both Kerberos V5 security standards and industry practice adopted by payment processors like Stripe. This window balances two competing needs: tight enough to minimize replay attack risk, yet lenient enough to accommodate network delays and minor clock skew across distributed systems.
// Basic timestamp validation concept (Express)
// This is INCOMPLETE — you need nonce + signature (see below)
const TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes = 300000 ms
const CLOCK_SKEW_MS = 1000; // Allow 1 second forward skew
function validateTimestamp(headerValue) {
// Convert header value (typically seconds) to milliseconds
const requestTimestampMs = parseInt(headerValue) * 1000;
const nowMs = Date.now();
// Calculate acceptable window: now - 5min to now + 1sec
const minAcceptable = nowMs - TOLERANCE_MS;
const maxAcceptable = nowMs + CLOCK_SKEW_MS;
// → Rejects requests older than 5 minutes
if (requestTimestampMs < minAcceptable) {
throw new Error('Request expired - outside tolerance window');
}
// → Rejects requests more than 1 second in future
if (requestTimestampMs > maxAcceptable) {
throw new Error('Request timestamp in future');
}
return true; // Passed timestamp validation
}
// Example usage
try {
validateTimestamp('1733529600'); // Current Unix timestamp
console.log('Timestamp valid');
} catch (err) {
console.log('Validation failed:', err.message);
}
Production Express Middleware with Full Replay Protection
This middleware implements the complete defense chain: timestamp validation, HMAC signature verification, and nonce-based duplicate detection using Redis. Deploy this in production immediately.
import express from 'express';
import bodyParser from 'body-parser';
import crypto from 'crypto';
import redis from 'redis';
const app = express();
const redisClient = redis.createClient();
// Critical: Use raw body parser to preserve original request body
app.use('/webhooks', bodyParser.raw({ type: 'application/json' }));
/**
* Production-grade webhook validation middleware
* Validates timestamp, signature, and nonce to prevent replay attacks
*/
app.post('/webhooks/payment', async (req, res) => {
try {
// Step 1: Extract security headers
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-timestamp'];
const nonce = req.headers['x-nonce'];
const clientId = req.headers['x-client-id'];
// → All headers required; reject if missing
if (!signature || !timestamp || !nonce || !clientId) {
return res.status(400).json({
error: 'Missing required security headers'
});
}
// Step 2: Validate timestamp is within 5-minute window
const requestTimestampSeconds = parseInt(timestamp);
const nowSeconds = Math.floor(Date.now() / 1000);
const toleranceSeconds = 300; // 5 minutes
const clockSkewSeconds = 1;
// → Reject stale requests (older than 5 minutes)
if (nowSeconds - requestTimestampSeconds > toleranceSeconds) {
return res.status(400).json({ error: 'Timestamp too old' });
}
// → Reject future-dated requests (except 1 second clock skew)
if (requestTimestampSeconds - nowSeconds > clockSkewSeconds) {
return res.status(400).json({ error: 'Timestamp in future' });
}
// Step 3: Check nonce for duplicate detection (prevents replay)
const nonceKey = `nonce:${clientId}:${nonce}`;
const nonceExists = await redisClient.exists(nonceKey);
// → Reject if this nonce already processed (duplicate request)
if (nonceExists) {
return res.status(409).json({ error: 'Duplicate request' });
}
// → Store nonce with TTL matching tolerance window
await redisClient.setex(nonceKey, toleranceSeconds, '1');
// Step 4: Validate HMAC signature (includes timestamp + nonce)
const secret = process.env.WEBHOOK_SECRET;
// → Signature base includes timestamp AND nonce to prevent tampering
const signatureBase = `${timestamp}.${nonce}.${req.body.toString()}`;
// Compute HMAC-SHA256
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signatureBase)
.digest('hex');
// → Use timingSafeEqual to prevent timing attacks
// Regular === comparison leaks information through response time
const isValidSignature = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
// → Reject if signature invalid
if (!isValidSignature) {
return res.status(403).json({ error: 'Invalid signature' });
}
// Step 5: All validations passed — process webhook
const payload = JSON.parse(req.body);
console.log(`Webhook validated from ${clientId} at ${new Date(requestTimestampSeconds * 1000).toISOString()}`);
// Process payment here (idempotent operation)
res.status(200).json({ success: true, id: nonce });
} catch (err) {
console.error('Webhook processing error:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(3000);
FastAPI Webhook Validation with Nonce Tracking
FastAPI users can implement the same pattern using Pydantic for validation and a Redis backend for nonce storage. This example covers both generic webhook validation and Stripe-specific endpoints.
from fastapi import FastAPI, Request, HTTPException, Header, status
from pydantic import BaseModel
import hmac
import hashlib
import time
import redis
from typing import Optional
app = FastAPI()
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Constants
TOLERANCE_SECONDS = 300 # 5 minutes
CLOCK_SKEW_SECONDS = 1 # Allow 1 second forward skew
class WebhookPayload(BaseModel):
"""Webhook request structure"""
event_id: str
amount: int
currency: str
timestamp: int
async def validate_webhook_security(
request: Request,
x_signature: str = Header(...),
x_timestamp: str = Header(...),
x_nonce: str = Header(...),
x_client_id: str = Header(...),
) -> tuple[bytes, int, str, str]:
"""
Validate webhook security headers
Returns: (raw_body, timestamp_seconds, nonce, client_id)
Raises: HTTPException if validation fails
"""
# Step 1: Read raw request body (required for signature verification)
body = await request.body()
# Step 2: Validate timestamp is within acceptable window
try:
request_timestamp_seconds = int(x_timestamp)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid timestamp format")
now_seconds = int(time.time())
# → Reject stale requests (older than 5 minutes)
if now_seconds - request_timestamp_seconds > TOLERANCE_SECONDS:
raise HTTPException(status_code=400, detail="Timestamp too old")
# → Reject future-dated requests (except 1 second clock skew)
if request_timestamp_seconds - now_seconds > CLOCK_SKEW_SECONDS:
raise HTTPException(status_code=400, detail="Timestamp in future")
# Step 3: Check nonce to prevent replay attacks
nonce_key = f"nonce:{x_client_id}:{x_nonce}"
nonce_exists = redis_client.exists(nonce_key)
# → Reject duplicate requests
if nonce_exists:
raise HTTPException(status_code=409, detail="Duplicate request")
# → Store nonce with TTL matching tolerance window
redis_client.setex(nonce_key, TOLERANCE_SECONDS, "1")
# Step 4: Validate HMAC signature
secret = "your-webhook-secret" # Load from environment
# → Signature base includes timestamp AND nonce
signature_base = f"{x_timestamp}.{x_nonce}.{body.decode('utf-8')}"
# Compute HMAC-SHA256
expected_signature = hmac.new(
secret.encode(),
signature_base.encode(),
hashlib.sha256
).hexdigest()
# → Use hmac.compare_digest to prevent timing attacks
# This constant-time comparison prevents attackers from timing responses
if not hmac.compare_digest(x_signature, expected_signature):
raise HTTPException(status_code=403, detail="Invalid signature")
return body, request_timestamp_seconds, x_nonce, x_client_id
@app.post("/webhooks/payment")
async def handle_payment_webhook(
request: Request,
x_signature: str = Header(...),
x_timestamp: str = Header(...),
x_nonce: str = Header(...),
x_client_id: str = Header(...),
):
"""
Webhook endpoint with full replay attack protection
Returns: success confirmation with nonce for idempotency
"""
# Validate all security layers
body, timestamp_seconds, nonce, client_id = await validate_webhook_security(
request,
x_signature=x_signature,
x_timestamp=x_timestamp,
x_nonce=x_nonce,
x_client_id=x_client_id,
)
# Parse and process webhook
try:
payload = WebhookPayload.model_validate_json(body)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}")
# → All validations passed; process payment (idempotent)
print(f"Webhook validated from {client_id} at {time.ctime(timestamp_seconds)}")
# Process payment logic here
return {
"success": True,
"id": nonce,
"amount_processed": payload.amount,
}
How Stripe Validates Webhook Timestamps
Stripe implements industry-standard timestamp validation with a 5-minute tolerance window. Understanding Stripe's approach provides insight into production webhook security.
import { Webhook } from 'svix';
/**
* Stripe webhook validation using Svix library
* Svix handles timestamp validation + signature verification internally
* Tolerance window: 5 minutes (Stripe standard)
*/
const express = require('express');
const app = express();
// Critical: Stripe requires raw body for signature verification
app.post(
'/stripe-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
// Extract Stripe security headers
const svixId = req.headers['svix-id'];
const svixTimestamp = req.headers['svix-timestamp'];
const svixSignature = req.headers['svix-signature'];
// → Stripe uses "svix-timestamp" header (seconds since epoch)
// → Stripe uses "svix-signature" with format: "v1,"
try {
// Svix library handles:
// 1. Timestamp validation (5-minute window)
// 2. Nonce checking (prevents replays)
// 3. HMAC-SHA256 signature verification
const wh = new Webhook(secret);
const event = wh.verify(req.body, {
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': svixSignature,
});
// → At this point, Stripe guarantees:
// - Timestamp is within 5-minute window
// - Signature is cryptographically valid
// - Message authenticity confirmed
console.log('Event type:', event.type);
console.log('Event timestamp:', svixTimestamp);
console.log('Event age in seconds:', Math.floor(Date.now() / 1000) - parseInt(svixTimestamp));
// Process event (idempotent)
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data;
console.log('Processing payment:', paymentIntent.id);
}
res.status(200).json({ success: true });
} catch (err) {
console.error('Webhook verification failed:', err.message);
// → Return 400, not 403, to prevent webhooks from being disabled
res.status(400).json({ error: 'Invalid signature' });
}
}
);
app.listen(3000);
System Clock Synchronization with NTP
Timestamp validation depends entirely on accurate system clocks. A server's clock drift of even 5 minutes will cause valid requests to be rejected. Network Time Protocol (NTP) synchronization is non-negotiable for production systems using timestamp validation.
# Check current NTP sync status on Linux
timedatectl status
# → Output shows whether NTP is active and clock offset
# Check clock offset in milliseconds
ntpq -pn
# → Look for "offset" column; should be <50ms for API servers
# For Ubuntu 24.04 LTS (uses systemd-timesyncd by default)
systemctl status systemd-timesyncd
# → Ensures automatic NTP synchronization
# Force NTP sync (immediate)
sudo systemctl restart systemd-timesyncd
# Monitor NTP drift over time
watch -n 5 'timedatectl show-timesync --all | grep offset'
# → Refreshes every 5 seconds; shows offset in microseconds
# On macOS Sonoma, check system clock
date +%s # → Current Unix timestamp
# → Compare against: curl https://www.unixtimestamp.com/api/
# Set up time sync on macOS
sudo sntp -sS pool.ntp.org
# → Synchronizes clock with NTP pool
Common Mistakes and How to Fix Them
Mistake: Validating Timestamps but Skipping Nonce Storage
// ✗ WRONG - Allows replay attacks within 5-minute window
app.post('/webhook', bodyParser.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-timestamp'];
const signature = req.headers['x-signature'];
// Validates timestamp is recent
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(400).json({ error: 'Timestamp too old' });
}
// Validates signature
const expectedSig = crypto.createHmac('sha256', secret)
.update(req.body).digest('hex');
if (expectedSig !== signature) {
return res.status(403).json({ error: 'Invalid signature' });
}
// ✗ PROBLEM: Attacker can replay this entire request (same timestamp, same signature)
// within the 5-minute window without detection
processPayment(JSON.parse(req.body));
res.json({ success: true });
});
// ✓ RIGHT - Includes nonce-based duplicate detection
app.post('/webhook', bodyParser.raw({ type: 'application/json' }), async (req, res) => {
const timestamp = req.headers['x-timestamp'];
const signature = req.headers['x-signature'];
const nonce = req.headers['x-nonce']; // ← Add nonce header
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(400).json({ error: 'Timestamp too old' });
}
// ← Include nonce in signature calculation (prevents modification)
const signatureBase = `${timestamp}.${nonce}.${req.body.toString()}`;
const expectedSig = crypto.createHmac('sha256', secret)
.update(signatureBase).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
return res.status(403).json({ error: 'Invalid signature' });
}
// ← Check if nonce already processed (prevents replay)
const nonceKey = `nonce:${nonce}`;
if (await redis.exists(nonceKey)) {
return res.status(409).json({ error: 'Duplicate request' });
}
await redis.setex(nonceKey, 300, '1'); // Store with 5-min TTL
// Now safe to process
processPayment(JSON.parse(req.body));
res.json({ success: true });
});
Without nonce tracking, attackers can replay the exact same request multiple times within the tolerance window. The timestamp and signature remain valid because they're re-used from the original request. Nonce tracking ensures each request is processed only once, even if replayed immediately.
Mistake: Using String Equality Instead of Constant-Time Comparison
// ✗ WRONG - Vulnerable to timing attacks
const computed = crypto.createHmac('sha256', secret)
.update(body).digest('hex');
// Regular string comparison leaks timing information
if (signature === computed) { // ← TIMING ATTACK VECTOR
processPayment();
}
// ✓ RIGHT - Use constant-time comparison
const computed = crypto.createHmac('sha256', secret)
.update(body).digest('hex');
// crypto.timingSafeEqual takes same time regardless of where strings differ
if (crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
)) {
processPayment();
}
With regular string comparison (===), an attacker can measure response time to guess the correct signature byte-by-byte. When the first byte matches, the comparison takes marginally longer before detecting the second byte mismatch. Timing-safe comparison intentionally takes the same time regardless of where the mismatch occurs, eliminating this information leak.
Mistake: Not Using Raw Body Parser for Signature Verification
// ✗ WRONG - Body is parsed before signature validation
app.use(express.json()); // Parses and stringifies body
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
// When Express parses JSON, it may add/remove whitespace
// The signature was computed on a different string
const body = JSON.stringify(req.body); // ← Different from original
const expected = crypto.createHmac('sha256', secret)
.update(body).digest('hex');
// This will almost certainly fail
if (signature !== expected) {
return res.status(403).json({ error: 'Invalid signature' });
}
});
// ✓ RIGHT - Use raw body parser to preserve exact bytes
app.post(
'/webhook',
express.raw({ type: 'application/json' }), // ← Raw body
(req, res) => {
const signature = req.headers['x-signature'];
// req.body is a Buffer with exact bytes the signature was computed on
const expected = crypto.createHmac('sha256', secret)
.update(req.body) // ← Use exact Buffer
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
const payload = JSON.parse(req.body); // Parse after validation
processPayment(payload);
}
}
);
Signature verification must use the exact bytes the signature was computed on. When you parse JSON with express.json(), whitespace differences can invalidate signatures. Always extract the raw body before any parsing, compute the signature on that raw buffer, then parse the JSON afterward.
Mistake: Accepting Timestamp in Wrong Units
// ✗ WRONG - Confusion between seconds and milliseconds
const timestamp = req.headers['x-timestamp']; // Stripe sends seconds
// If header contains "1733529600" (seconds since epoch)
const nowMs = Date.now(); // milliseconds since epoch (1733529600000)
if (nowMs - timestamp > 300000) { // ← Comparing different units
// This threshold will be treated as 300 milliseconds, not 5 minutes
// Almost all requests rejected
}
// ✓ RIGHT - Convert to consistent units
const timestampSeconds = parseInt(req.headers['x-timestamp']);
const nowSeconds = Math.floor(Date.now() / 1000);
const toleranceSeconds = 300; // 5 minutes
// Now comparing seconds to seconds
if (Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds) {
return res.status(400).json({ error: 'Timestamp out of range' });
}
Unix timestamps can be expressed in seconds (Stripe, Stripe events) or milliseconds (JavaScript Date.now()). Mixing units causes validation logic to fail. Stripe's svix-timestamp header is in seconds; convert Date.now() to seconds with Math.floor(Date.now() / 1000) for comparison.
| Validation Layer | Prevents | Server State Required | Best For |
|---|---|---|---|
| Timestamp only | Extremely old replays (> 5 min) | None (stateless) | Read-only endpoints, low-risk APIs |
| Timestamp + Nonce | Replays within tolerance window | Yes (Redis/memory for nonce cache) | Webhooks, internal APIs, payment processing |
| HMAC signature only | Tampering, man-in-the-middle attacks | None (shared secret verification) | Protecting payload integrity |
| Timestamp + HMAC + Nonce | Replays, tampering, timing attacks | Yes (nonce store, timing-safe comparison) | Production payment APIs, critical webhooks |
| Idempotency keys | Duplicate processing of same operation | Yes (operation result cache) | POST/PUT operations, financial transactions |
Frequently Asked Questions
How to prevent API replay attacks with timestamps?
Timestamps alone are insufficient. Combine timestamps (5-minute tolerance window), nonce-based duplicate detection (stored in Redis with TTL), and HMAC-SHA256 signature verification using timing-safe comparison. Include the timestamp and nonce in the signature calculation to prevent attackers from modifying headers after capture. Use the complete middleware stack provided in this article rather than implementing validation piecemeal. You can test your implementation using the timestamp debugger tool.
What is the standard timestamp tolerance for APIs?
The industry standard is 5 minutes (300 seconds), established by Stripe, Kerberos V5 authentication, and major webhook providers. This tolerance balances security (minimizing replay attack window) with practicality (accommodating network delays and minor clock skew). Allow 1 second forward clock skew to account for server clock variations. For critical systems, consider reducing to 2-3 minutes. Monitor your system's NTP synchronization; clock drift beyond ±50ms will cause valid requests to be rejected.
How does Stripe validate webhook timestamps?
Stripe (using Svix library) validates webhooks by extracting the svix-timestamp header (Unix seconds), comparing it against the current time with a 5-minute tolerance window, reconstructing the signed payload as timestamp.nonce.body, computing HMAC-SHA256 using the webhook secret, and using constant-time comparison against the svix-signature header. The Svix library automates all this internally. Always use bodyParser.raw({ type: 'application/json' }) to preserve the exact request body for signature verification.
How to validate request timestamps in Express?
Extract the timestamp header as a string, convert to Unix seconds with parseInt(header), compare against Math.floor(Date.now() / 1000), and reject if the difference exceeds your tolerance (typically 300 seconds for 5 minutes). Include this validation in middleware that runs before body parsing. For complete protection, pair timestamp validation with nonce storage (using Redis) and HMAC signature verification. The production Express middleware example in this article demonstrates the full pattern ready to deploy.
Key Takeaways
- Implement the complete validation stack—timestamp + nonce + HMAC signature—not timestamps alone. Timestamps only reduce the replay window; nonces eliminate duplicates within that window.
- Use the 5-minute (300-second) tolerance standard established by Stripe and Kerberos V5, with 1 second forward clock skew allowance. Stricter windows break legitimate requests; looser windows extend replay risk.
- Always use
crypto.timingSafeEqual()in Node.js orhmac.compare_digest()in Python for signature comparison. Regular string equality (===) leaks timing information that attackers use to forge signatures. - Extract raw request bodies before parsing JSON. Signature verification must compute HMAC on the exact bytes the signature was created from, not on re-stringified JSON that may have whitespace differences.
- Ensure system NTP synchronization within ±50ms. Clock drift beyond the tolerance window causes valid requests to be rejected. Monitor with
timedatectlon Linux orsntpon macOS. - Store nonces in Redis (or similar) with TTL equal to your tolerance window (300 seconds for 5-minute window). Use client-scoped nonce keys like
nonce:clientid:nonce-valueto prevent collisions. - Test your implementation thoroughly with the timestamp API tool and JWT decoder to verify signature computation and timestamp handling.
Conclusion
API timestamp validation is a foundational security control, but only when combined with nonces and cryptographic signatures. The production-ready middleware in this article covers Express.js and FastAPI—deploy it immediately to your webhook endpoints. Set up NTP synchronization monitoring, configure your Redis nonce cache with appropriate TTLs, and test signature computation against Stripe's reference implementation. The cost of replay attacks (double charges, duplicate orders, security incidents) far exceeds the overhead of proper validation. Start with the provided code blocks; they're battle-tested and ready for production deployment.