Stripe webhook signature verification can fail on replayed events because each signature includes a timestamp that must be within a five-minute tolerance window, and the signature itself is computed over that timestamp plus the raw payload. When an event is replayed later (manually from the Dashboard or via an automated retry after the tolerance has passed), the old timestamp falls outside the window and verification rejects it. The fix is to either replay within five minutes, or adjust your verification logic to accept older timestamps when you intentionally process replays, while still guarding against actual replay attacks.
Why Stripe Signs Webhooks
Stripe signs every webhook request with an Stripe-Signature header. The header contains a timestamp and one or more signatures (comma-separated, each with a scheme like v1). The signature is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, using your webhook signing secret as the key. Your server verifies the signature to confirm the request genuinely came from Stripe and that the payload hasn’t been tampered with in transit.
The timestamp serves two purposes: it’s part of the signed data, and it lets you reject old messages. Stripe’s official libraries enforce a default tolerance of 300 seconds (five minutes). If the timestamp in the header is older than that, verification fails with a SignatureVerificationError or similar exception. That’s the core mechanism that causes replay failures.
What a Replay Is and Why It Breaks Verification
A replay is simply sending the same webhook request again. Stripe lets you replay events from the Dashboard (on the event’s detail page) or via the API. When you click “replay,� Stripe re-sends the exact same signed request—same payload, same timestamp, same signature. If you do this within five minutes of the original send, verification succeeds. If you do it later (which is common when debugging an issue that occurred hours ago), the timestamp is now outside the tolerance, and verification fails.
This is by design: the timestamp is the primary defense against replay attacks. An attacker who captures a valid webhook request could otherwise re-send it repeatedly to trigger duplicate side effects (e.g., charging a customer twice). The five-minute window limits that risk. But it also means that legitimate replays from Stripe’s own Dashboard will fail if you don’t handle them specially.
How Stripe’s Verification Works Under the Hood
Here’s the exact algorithm used by Stripe’s official SDKs (e.g., stripe-python, stripe-node):
- Extract the
Stripe-Signatureheader. - Split it on commas to get each
t=timestampandv1=signaturepair. - Find the timestamp (the
tvalue). - Compute the expected signature as
HMAC-SHA256(secret, timestamp + "." + raw_body). - Compare the expected signature to the provided
v1signature using a constant-time comparison. - Check that the timestamp is within your tolerance (default 300 seconds). If not, throw an error.
If any step fails, verification raises an exception and your webhook handler should return a 4xx status. Stripe will then retry the webhook (with a new signature and timestamp) according to its retry schedule—typically up to three days with exponential backoff. Those retries are fresh requests, so they verify fine. The problem only occurs when you manually replay an old event.
How to Fix Replay Failures
You have several options, depending on your needs:
- Replay within five minutes. If you’re debugging and just need to re-trigger a webhook, do it quickly. This is the simplest workaround but often impractical.
- Increase the tolerance in your verification code. Most Stripe SDKs let you pass a
toleranceparameter (e.g., in Python:Webhook.construct_event(payload, sig_header, secret, tolerance=3600)). Setting it to a larger value (say 1 hour or 1 day) will allow old timestamps. But this weakens replay protection—any captured request within that window can be replayed. Only do this if you have other safeguards, such as idempotency keys. - Implement idempotency handling. Even with a short tolerance, you should make your webhook handler idempotent. That means tracking processed event IDs (e.g., in a database with a unique constraint) and ignoring duplicates. If you do that, you can safely increase the tolerance or even skip the timestamp check for events you’ve already seen.
- Use Stripe’s API to fetch the event instead of relying on the payload. When you receive a webhook, you can call
GET /v1/events/{id}to retrieve the latest version of the event from Stripe’s API. This always returns fresh data and avoids the replay problem entirely. However, it adds an API call and requires you to have the event ID from the payload.
For most production systems, the recommended approach is: verify the signature normally with a five-minute tolerance, but also store processed event IDs. If your verification fails because the timestamp is too old, you can optionally fetch the event from the API to confirm it’s legitimate before processing. That gives you both security and the ability to handle replays.
Common Mistakes and Misconceptions
- Mistaking a replay for a signature mismatch. The error message may say “No signatures found matching the expected signature� even when the signature is correct—because the timestamp check happens after the signature comparison. Always check the timestamp in the header first.
- Using the wrong secret. The signing secret is different from your API key. It’s found in the webhook endpoint settings (e.g.,
whsec_...). Using the API key will cause all verifications to fail. - Not using the raw body. Your verification must use the exact raw request body as received. If you parse JSON and re-serialize it, the signature won’t match. Always pass the raw bytes to the verification function.
- Assuming retries are replays. Stripe’s automatic retries send a fresh request with a new timestamp, so they verify fine. Only manual replays from the Dashboard cause the issue.
Security Considerations
The five-minute tolerance is a trade-off between security and reliability. A shorter window (e.g., 1 minute) reduces the replay attack surface but makes manual replays even harder. A longer window (e.g., 1 hour) makes replays easier but increases the window in which a stolen request can be replayed. There’s no one-size-fits-all; you must balance your risk tolerance with your operational needs.
If you increase the tolerance, you must also implement idempotency. Otherwise, an attacker could replay a captured request within the window and cause duplicate charges, refunds, or other side effects. Idempotency is a best practice regardless, since Stripe itself may deliver the same event more than once (e.g., due to network retries).
Another safety measure is to log the event ID and timestamp of every webhook you process. If you ever see an event with a timestamp far in the past, it’s either a manual replay or an attack—investigate accordingly.
Alternatives to Manual Replays
If you need to re-process an event because your endpoint was down or had a bug, consider these alternatives:
- Use Stripe’s automatic retries. Stripe retries failed webhooks for up to three days. If your endpoint recovers within that window, you’ll get the event automatically—no manual action needed.
- Fetch the event via API. Write a small script that lists recent events and processes them through your normal handler. This gives you full control and avoids the signature issue entirely.
- Trigger a new event. For some events (e.g.,
invoice.paid), you can trigger a new one by performing the action again (e.g., paying the invoice). This creates a fresh webhook with a new timestamp.
Frequently Asked Questions
Why does Stripe’s replay fail even though the signature matches?
Because the signature is valid, but the timestamp is older than the tolerance (default 5 minutes). Verification checks both the signature and the timestamp. The signature can be cryptographically correct, yet the request is rejected because it’s too old.
Can I just disable the timestamp check?
Yes, you can pass a very large tolerance or skip the check entirely. But that removes replay protection, making your endpoint vulnerable to replay attacks. Only do this if you have a robust idempotency system and understand the risk.
What’s the difference between a replay and a retry?
A retry is Stripe automatically re-sending a webhook after a failure, and it includes a fresh timestamp. A replay is a manual action from the Dashboard or API that re-sends the exact original request, including the old timestamp. Retries verify fine; replays may not.
How do I handle a replay in my code without weakening security?
First, verify the signature normally. If it fails due to an old timestamp, fetch the event from Stripe’s API using the event ID from the payload. If the event exists and is in a state you expect, process it. This way you only accept replays that Stripe confirms are real.
Does Stripe’s tolerance apply to all languages?
Yes, all official Stripe SDKs implement the same algorithm with a default tolerance of 300 seconds. The parameter name may differ (e.g., tolerance in Python, tolerance in Node, tolerance in PHP), but the behavior is identical.
What if I’m using a third-party library that doesn’t expose tolerance?
You can implement the verification manually. The algorithm is public: extract the timestamp, compute HMAC-SHA256 over timestamp + “.” + body with your secret, compare, then check the timestamp. If your library doesn’t allow changing the tolerance, you can fork it or write a custom verification function.
Related
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.
More cards in this drawer
- Philips DST7060/20 7000 Series Iron: Specs & Fit
- Coal Fueled Jet Aircraft: Could It Work?675 L
- Remote Desktop on Raspberry Pi: How to Enable It
- Word Select Last Line in Paragraph?
- Google Assistant IFTTT Recipes: 7 Useful Automations
- OLED TVs in Dark Rooms: Why They Look Better and When They Don’t