Replay protection
Replay protection
A signature proves a payload came from someone holding the secret. It says nothing about when, or about how many times it may be acted on. Those are separate problems with separate defences.
Layer 1: the timestamp window
Schemes that bind a timestamp into the signed bytes — Stripe, Slack, Standard Webhooks, Mailgun, Cbox — give a delivery an expiry. The timestamp cannot be changed without invalidating the MAC, so a captured request stops being useful once it falls outside the tolerance window.
'endpoints' => [
'stripe' => ['scheme' => 'stripe', 'secrets' => [...], 'tolerance' => 300],
],
The window is two-sided. Rejecting only old timestamps would leave a forward-dated delivery valid for as long as the sender cared to post-date it, which reopens the hole the window exists to close.
Omit tolerance to inherit the scheme's default (300s where the provider binds a
timestamp). Set it to null to disable the window — legitimate when replaying recorded
traffic, and a downgrade in production.
This layer needs an accurate clock. A server minutes behind rejects genuine deliveries as future-dated; one minutes ahead rejects them as stale. NTP is a prerequisite, not a nicety.
Schemes with no window
GitHub, Shopify and Twilio bind no timestamp, so there is nothing to check and those
schemes report a null default. A captured delivery from those providers stays
cryptographically valid indefinitely. Configuration cannot change that, and this package
will not pretend otherwise.
For those, layer 2 is the only replay defence available.
Layer 2: single-use enforcement
The window bounds how long a captured delivery is useful. Inside that time, a replay is still a valid request. Closing the gap needs memory of what has already been processed.
'replay' => [
'enabled' => true,
'store' => 'redis',
'ttl' => 3600,
],
Each verified delivery is recorded by its event id — GitHub's X-GitHub-Delivery,
Shopify's X-Shopify-Webhook-Id, the Standard Webhooks webhook-id, Stripe's evt_… —
and a second arrival with the same id is refused with FailureReason::Replayed.
When the provider sends no id, the guard falls back to a digest of the signature itself. Keys are hashed before they reach the store, so a cache dump never holds signature material or provider event ids.
Why it is off by default
It is only correct against a store shared by every node. On array or file, each node
remembers only its own traffic, so a replay merely has to land on a different node — a
control that reports success while enforcing nothing.
This package cannot tell whether your configured store qualifies, and a security control that silently does nothing is worse than an absent one. So it stays off until an operator turns it on having chosen the store. Redis, Memcached, or a shared database store are the correct choices.
TTL
At least as long as the tolerance window, or a delivery could age out of the guard while still passing the timestamp check. An hour is a reasonable default; longer costs memory and buys protection against replays the window has already rejected.
Per-endpoint opt-out
'endpoints' => [
'partner' => ['scheme' => 'hmac', 'secrets' => [...], 'replay' => false],
],
For a provider that legitimately redelivers the same event id. An endpoint can opt out but cannot opt in past the master switch, because the switch is what asserts the store is shared.
What neither layer gives you
Single-use enforcement refuses a duplicate delivery. It cannot tell you whether the work the first one triggered actually finished — if your handler crashed halfway through, the guard will happily refuse the provider's retry of an event you never completed.
Idempotency in your own handler is a separate concern and remains your job. Use
VerifiedWebhook::idempotencyKey() as the key; it returns the provider's event id, which is
what stays constant across their retries.