Skip to content

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.