Choosing a timestamp tolerance window for webhook verification
Every timestamped webhook verifier has one number in it that nobody can justify: the tolerance. You inherited 300000 from a code sample, it has never caused an incident you noticed, and nobody can say what would break if it were 60000 or 900000. This guide is about deriving that number from measurements instead of folklore — it is the tuning companion to Preventing webhook replay attacks with timestamps, which builds the check itself, and sits under the Replay Attack Prevention reference. The scenario is narrow: the check is already live and fail-closed, and you need to decide what the bound should be for your traffic, defend it in a review, and change it without silently dropping production events.
The window is a single dial between two failure modes. Widen it and a captured request stays replayable for longer; narrow it and legitimate deliveries that were merely slow get rejected with a 400 that looks, to the sender, exactly like an outage. Neither cost is symmetric or theoretical: the replay cost is bounded by whatever else you run — a nonce store makes replays inside the window harmless — while the false-rejection cost lands on real customer events during exactly the incidents (congestion, backlog, failover) that already hurt.
What the window actually buys, and what it costs
A timestamp bound alone does not stop replays; it caps how long a stolen request remains useful. That cap is only meaningful in combination with what runs after it. If every accepted delivery claims a single-use nonce, as in Nonce-based replay protection with Redis, an attacker inside the window gets nothing, and the window’s job shrinks to bounding how much state the nonce store must retain. If you have no nonce store, the window is your replay defence and every second of it is exposure.
On the other side of the dial sits the age of legitimate traffic, which is almost never what engineers assume. The timestamp is stamped when the sender signs, not when the request arrives, so everything between those two moments is charged against your window: transit, the provider’s own retry backoff, and — if you validate after queueing rather than at ingress — your own consumer backlog.
Five minutes became the de-facto default because it is comfortably larger than the delivery age of a healthy sender on a healthy network, and comfortably smaller than the hours an attacker would want. Stripe’s SDKs ship a 300-second default tolerance, Slack’s request-signing guidance rejects anything older than five minutes, and Svix uses the same figure — so most integrations copy it, and providers now design their retry schedules around receivers that use it. That makes 300 seconds a sensible starting point and a poor final answer.
| Sender | Documented window | What actually forces the number |
|---|---|---|
| Stripe | 300s default in the SDK | Signature timestamp is re-stamped per attempt, so retries stay young |
| Slack | 300s recommended | Interactive traffic; ages are sub-second outside incidents |
| Svix-based providers | 300s tolerance | Shared default across many downstream integrations |
| GitHub | no timestamp header | Nothing to tune; deduplicate on the delivery GUID instead |
| Your internal dispatcher | you decide | Your own retry schedule and queue backlog set the floor |
Prerequisites
- Runtime: Node.js 20+ with TypeScript 5.4 and an Express 4 verifier you already control.
- A working timestamp check that parses a signed timestamp header and fails closed, such as the middleware in Preventing webhook replay attacks with timestamps.
prom-client15+ (npm i prom-client) and a metrics scrape you can query — you cannot tune what you do not measure.chrony4.x (orntpd) running on every verifier host, with permission to runchronyc tracking.- The sender’s published retry schedule, and whether it re-signs on each attempt or replays the original signed timestamp. This one fact moves the answer by minutes.
- Vitest 1.6+ for the boundary tests in the verification section.
Step 1: Measure the true age of legitimate deliveries
Instrument before you tune. Record the age of every delivery that passes signature verification, labelled by sender and by whether the current window accepted it, and let it run for at least one full week so a retry storm and a backlog event land inside the sample.
import express, { type Request, type Response, type NextFunction } from 'express';
import { Histogram, Counter } from 'prom-client';
// Age buckets are log-ish and dense around the thresholds you might pick.
export const deliveryAge = new Histogram({
name: 'webhook_delivery_age_seconds',
help: 'Signed-at to validated-at age of an inbound webhook',
labelNames: ['sender', 'verdict'] as const,
buckets: [0.25, 0.5, 1, 2, 5, 15, 30, 60, 120, 180, 240, 300, 600, 1800],
});
// Ages below zero mean the sender's clock is ahead of ours; a histogram
// cannot hold negatives, so future skew gets its own instrument.
export const futureSkew = new Histogram({
name: 'webhook_future_skew_seconds',
help: 'How far into the future an inbound timestamp claims to be',
labelNames: ['sender'] as const,
buckets: [0.1, 0.5, 1, 2, 5, 15, 30, 60, 300],
});
export const missingTimestamp = new Counter({
name: 'webhook_timestamp_missing_total',
help: 'Deliveries with no parseable timestamp header',
labelNames: ['sender'] as const,
});
export function observeAge(sender: string, signedAtSeconds: number, verdict: 'accept' | 'reject'): number {
const ageSeconds = Date.now() / 1000 - signedAtSeconds;
if (ageSeconds < 0) {
futureSkew.labels(sender).observe(Math.abs(ageSeconds));
} else {
deliveryAge.labels(sender, verdict).observe(ageSeconds);
}
return ageSeconds;
}
export function ageProbe(sender: string) {
return (req: Request, _res: Response, next: NextFunction) => {
const raw = req.header('x-webhook-timestamp');
const signedAt = raw ? Number(raw) : Number.NaN;
if (!Number.isFinite(signedAt)) {
missingTimestamp.labels(sender).inc();
return next();
}
// Attach the age so the enforcing middleware can log it with its verdict.
(req as Request & { webhookAgeSeconds?: number }).webhookAgeSeconds = observeAge(sender, signedAt, 'accept');
next();
};
}
Query the resulting histogram for the numbers that matter — histogram_quantile(0.99, sum by (le, sender) (rate(webhook_delivery_age_seconds_bucket[7d]))) gives the p99 age per sender, and the same expression at 0.999 gives you the tail that a tight window will start clipping. If you already track webhook delivery latency percentiles, this is the same discipline applied to the signing clock instead of the wire.
Step 2: Measure clock skew across your fleet and the sender’s
There are two clocks in the comparison and you only administer one. Export the local NTP offset from every verifier so you know your own contribution, then read the sender’s contribution off the negative tail of the age distribution: a delivery that appears to arrive before it was signed is proof that the sender’s clock leads yours.
Export your own offset as a gauge so a drifting verifier shows up as an infrastructure alert rather than as mysterious signature rejections:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Gauge } from 'prom-client';
const run = promisify(execFile);
export const ntpOffset = new Gauge({
name: 'host_ntp_offset_seconds',
help: 'Absolute NTP offset of this verifier host, from chronyc tracking',
});
// `chronyc -c tracking` prints CSV; field 4 is the last measured offset in seconds.
export async function sampleNtpOffset(): Promise<number> {
const { stdout } = await run('chronyc', ['-c', 'tracking']);
const fields = stdout.trim().split(',');
const offset = Math.abs(Number(fields[4]));
if (!Number.isFinite(offset)) throw new Error(`unparseable chronyc output: ${stdout}`);
ntpOffset.set(offset);
return offset;
}
// Sample every 30s from the same process that serves /metrics.
setInterval(() => {
sampleNtpOffset().catch((err) => console.error({ event: 'ntp_sample_failed', err: String(err) }));
}, 30_000).unref();
A healthy fleet on public NTP holds well under 50ms; anything above a second means the daemon is not running, the host is virtualised without a paravirtual clock source, or an egress rule is blocking UDP 123. Treat the sender’s skew the same way: take histogram_quantile(0.999, ...) over webhook_future_skew_seconds and use that, not a guess, as the future-side requirement.
Step 3: Size the past and future bounds separately
The two directions mean different things, so a single symmetric Math.abs(delta) throws away information. A delivery that is old is either legitimately delayed or a replay. A delivery that is early cannot be legitimate at all — no correct sender signs the future — so the only reason to allow any future slack is clock disagreement, and the only honest size for that slack is your measured skew plus a small margin.
Encode both bounds in one config object so the numbers are reviewable, environment-overridable, and logged with every rejection:
import { Counter } from 'prom-client';
export interface WindowConfig {
/** How far in the past a signed timestamp may be, in milliseconds. */
pastMs: number;
/** How far in the future a signed timestamp may be, in milliseconds. */
futureMs: number;
}
export const enforcedWindow: WindowConfig = {
pastMs: Number(process.env.WEBHOOK_WINDOW_PAST_MS ?? 300_000),
futureMs: Number(process.env.WEBHOOK_WINDOW_FUTURE_MS ?? 30_000),
};
export type WindowVerdict =
| { ok: true; deltaMs: number }
| { ok: false; deltaMs: number; reason: 'too_old' | 'too_far_future' };
export function checkWindow(signedAtMs: number, cfg: WindowConfig, nowMs = Date.now()): WindowVerdict {
const deltaMs = nowMs - signedAtMs; // positive = old, negative = future
if (deltaMs > cfg.pastMs) return { ok: false, deltaMs, reason: 'too_old' };
if (-deltaMs > cfg.futureMs) return { ok: false, deltaMs, reason: 'too_far_future' };
return { ok: true, deltaMs };
}
export const windowRejects = new Counter({
name: 'webhook_window_reject_total',
help: 'Deliveries rejected by the timestamp window',
labelNames: ['sender', 'reason', 'mode'] as const,
});
Two constraints tie the past bound to the rest of the system. First, any nonce or idempotency key TTL must be greater than or equal to pastMs; if the key expires first, a replay arriving in the gap is accepted twice. Second, if the sender re-signs on every retry attempt — as Stripe does — retry backoff never accumulates against your window, and you can run a much tighter past bound than the 300-second default.
Step 4: Ship the new window in shadow mode before enforcing it
Never change this number in a single deploy. Run the candidate alongside the enforced window, count what it would have rejected, and let that counter sit at zero for a full traffic cycle — including a weekend and a month-end batch — before you promote it.
import type { Request, Response, NextFunction } from 'express';
import { checkWindow, enforcedWindow, windowRejects, type WindowConfig } from './window.js';
const candidateWindow: WindowConfig | null = process.env.WEBHOOK_WINDOW_CANDIDATE_PAST_MS
? {
pastMs: Number(process.env.WEBHOOK_WINDOW_CANDIDATE_PAST_MS),
futureMs: Number(process.env.WEBHOOK_WINDOW_CANDIDATE_FUTURE_MS ?? 30_000),
}
: null;
export function timestampWindowGate(sender: string) {
return (req: Request, res: Response, next: NextFunction) => {
const signedAtMs = Number(req.header('x-webhook-timestamp')) * 1000;
if (!Number.isFinite(signedAtMs)) {
windowRejects.labels(sender, 'unparseable', 'enforced').inc();
return res.status(400).json({ error: 'missing or unparseable timestamp' });
}
const enforced = checkWindow(signedAtMs, enforcedWindow);
// Shadow evaluation never affects the response; it only produces evidence.
if (candidateWindow) {
const shadow = checkWindow(signedAtMs, candidateWindow);
if (!shadow.ok && enforced.ok) {
windowRejects.labels(sender, shadow.reason, 'shadow').inc();
console.warn({
event: 'window_shadow_reject',
sender,
delta_ms: shadow.deltaMs,
reason: shadow.reason,
candidate_past_ms: candidateWindow.pastMs,
});
}
}
if (!enforced.ok) {
windowRejects.labels(sender, enforced.reason, 'enforced').inc();
console.warn({ event: 'window_reject', sender, delta_ms: enforced.deltaMs, reason: enforced.reason });
return res.status(400).json({ error: 'timestamp outside tolerance window' });
}
next(); // signature verification and the nonce claim run next
};
}
Promote by swapping the candidate values into WEBHOOK_WINDOW_PAST_MS, keep the previous values one deploy away, and treat webhook_window_reject_total{mode="enforced"} as a paging metric for the first 24 hours. Tightening is the risky direction; widening only ever costs you replay exposure, which is why an incident-time widen is safe and an incident-time tighten is not.
Verification and testing
Boundary behaviour is exactly where an off-by-one lives, so test the edges directly with a frozen clock rather than sampling production and hoping.
import { describe, expect, it } from 'vitest';
import { checkWindow } from './window.js';
const NOW = 1_760_000_000_000; // fixed epoch ms
const cfg = { pastMs: 300_000, futureMs: 30_000 };
describe('checkWindow', () => {
it('accepts a delivery exactly at the past bound', () => {
expect(checkWindow(NOW - 300_000, cfg, NOW)).toEqual({ ok: true, deltaMs: 300_000 });
});
it('rejects one millisecond beyond the past bound', () => {
expect(checkWindow(NOW - 300_001, cfg, NOW)).toMatchObject({ ok: false, reason: 'too_old' });
});
it('accepts modest future skew within the future bound', () => {
expect(checkWindow(NOW + 25_000, cfg, NOW)).toMatchObject({ ok: true });
});
it('rejects a timestamp further ahead than the future bound', () => {
expect(checkWindow(NOW + 45_000, cfg, NOW)).toMatchObject({ ok: false, reason: 'too_far_future' });
});
it('is asymmetric: 200s old passes where 200s future does not', () => {
expect(checkWindow(NOW - 200_000, cfg, NOW).ok).toBe(true);
expect(checkWindow(NOW + 200_000, cfg, NOW).ok).toBe(false);
});
});
Then confirm the deployed endpoint agrees, by signing a deliberately stale timestamp:
SECRET="$WEBHOOK_SECRET"
STALE=$(( $(date -u +%s) - 400 )) # 400s old, outside a 300s past bound
BODY='{"event":"invoice.paid"}'
SIG=$(printf '%s.%s' "$STALE" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
# Expect 400 with reason too_old, NOT 401: the window must reject before signature work.
curl -sS -o /dev/null -w '%{http_code}\n' https://hooks.example.com/webhooks \
-H "X-Webhook-Timestamp: $STALE" -H "X-Webhook-Signature: sha256=$SIG" \
-H 'Content-Type: application/json' -d "$BODY"
Finally, assert the shadow counter in CI or a soak job: sum(increase(webhook_window_reject_total{mode="shadow"}[7d])) must be 0 before the candidate is promoted, and webhook_delivery_age_seconds p999 must remain below the candidate pastMs with at least 20% headroom.
Failure modes and gotchas
- The sender signs once and retries with the original timestamp. Its retry schedule then lands entirely inside your window budget: a provider that retries for 10 minutes will have every late attempt rejected by a 300-second bound, and those look like delivery failures on the sender’s dashboard, not on yours. Read the provider’s docs, confirm with a captured retry, and either widen the past bound past their final attempt or accept and document the truncation. Your own dispatcher should re-sign per attempt — that is a design decision worth making explicitly alongside your retry budgets and max attempts.
- You validate after queueing, so your backlog eats the window. If the timestamp check runs in the worker rather than at ingress, a 4-minute queue backlog turns into mass rejections precisely when you are already behind — and the rejections destroy events you had successfully accepted. Always evaluate the window at ingress, persist the verdict, and let the worker trust it.
- A symmetric window hides a badly skewed sender. With
Math.abs(delta) < 300s, a sender whose clock runs four minutes fast passes silently until the day it drifts to six and every delivery fails at once. The tight future bound turns that slow drift into an early, quiet alert instead of a cliff. - Tightening the window without raising the nonce TTL. The replay defence is
min(pastMs, nonceTtl), not the window alone: if you shrink the window to 120s but the nonce TTL is 60s, a replay at 90 seconds is accepted a second time. Keep the TTL at or abovepastMsin the same commit, as covered in Nonce-based replay protection with Redis.
Frequently Asked Questions
Should every sender share one window, or should each get its own?
Per sender, as soon as you have more than one. A single global number is necessarily the maximum across all of them, so the slowest and most skewed integration sets the replay exposure for every well-behaved one as well. The age histogram is already labelled by sender, so per-sender bounds fall out of data you are collecting anyway; keep one conservative default for senders you have not measured yet.
Should the past bound come from p99 or p999 of the measured age?
Use p999 and add headroom: at a million deliveries a month, sizing to p99 knowingly discards ten thousand legitimate events a month. The subtler trap is that the distribution is censored, since it can only contain deliveries the current bound already accepted, so it cannot show you how much traffic sits beyond today's threshold. Record the age of rejected deliveries as well, or every measurement will simply confirm the window you already have.
What do we do about a provider that sends no timestamp header at all?
There is nothing to size, and the temporal bound drops out of your defences entirely, which promotes the delivery-identifier store to being the whole replay control. That changes its retention requirement completely: rather than matching a window, it has to outlive the provider's entire retry schedule plus a margin, which is typically hours rather than minutes. Budget that memory up front, and prefer a durable uniqueness constraint over a cache once the retention runs that long.
Why do redeliveries triggered from a provider's dashboard fail the window?
Because some providers replay the original signed request byte for byte, timestamp included, so an event you ask them to resend three days later arrives three days old and is correctly rejected. Providers that re-sign on redelivery never show this symptom, which is why the behaviour surprises teams that switch integrations. Confirm which one yours does before an incident, and give support an authenticated internal replay path rather than widening the production bound to make a dashboard button work.
What future bound should we set if no delivery ever arrives early?
Not zero. Seeing no future skew usually means the sender's clock currently trails yours rather than that it can never lead, and a bound of zero converts a few hundred milliseconds of ordinary drift into total rejection. A fixed value in the tens of seconds absorbs that drift while still catching a sender whose clock is minutes ahead, which is the reason to keep the bound tight in the first place.
Where in the request lifecycle should the clock be sampled?
As early as possible, because everything between arrival and the comparison is charged against the sender's age budget rather than yours. TLS negotiation, reading a large body, and event-loop lag on a saturated instance each add hundreds of milliseconds, and under real load that shows up as rejections correlated with your own CPU instead of with the network. Sample once at the top of the middleware chain and pass the value down rather than calling the clock again in each check.
Related
- Preventing webhook replay attacks with timestamps — the fail-closed check this page supplies the numbers for.
- Nonce-based replay protection with Redis — makes replays inside the window harmless, which lets the window stay wide.
- Replay Attack Prevention — the threat model and layered defences behind both checks.
- Idempotency keys vs deduplication windows — the same TTL-versus-window trade-off on the processing side.