Stripe Webhook Signature Verification Deep Dive
Receiving webhooks is a cornerstone of modern application development, enabling real-time reactions to events in external systems like Stripe. But with great power comes great responsibility – specifically, the responsibility to ensure the webhooks you receive are legitimate and haven't been tampered with. This is where Stripe's webhook signature verification comes in.
Ignoring signature verification is like leaving your front door unlocked. An attacker could spoof a Stripe event, potentially triggering fraudulent actions in your system, like granting premium access without payment or processing refunds that never happened. In this deep dive, we'll peel back the layers of Stripe's signature verification process, understand its components, walk through the steps, and highlight common pitfalls.
Why Signature Verification Matters
Before we get into the "how," let's quickly reiterate the "why." Stripe sends sensitive event data to your application. Without verification, an adversary could:
- Impersonate Stripe: Send fake
payment_succeededevents to trick your system into providing services for unpaid transactions. - Replay Attacks: Capture a legitimate webhook and resend it later to trigger duplicate actions (e.g., double-charging a customer or processing multiple refunds).
- Tamper with Data: Modify the webhook payload to change amounts, customer IDs, or other critical information.
Stripe's signature provides cryptographic assurance that the webhook originated from Stripe and that its payload hasn't been altered in transit.
The Anatomy of a Stripe Webhook Event
When Stripe sends a webhook to your endpoint, it's an HTTP POST request. The core components relevant to verification are:
- Request Body: This is the JSON payload containing the event data (e.g.,
customer.created,checkout.session.completed). Stripe-SignatureHeader: This special header holds the crucial information needed to verify the request's authenticity.
Let's focus on that Stripe-Signature header. It typically looks something like this:
t=1678886400,v1=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
This header is a comma-separated list of key-value pairs. The two most important are:
t: This is the Unix timestamp (in seconds) of when the event was signed by Stripe. It's crucial for mitigating replay attacks.v1: This is one or more signatures generated using HMAC-SHA256. If multiplev1signatures are present (e.g., due to webhook secret rotation), you'll need to check each one.
The Verification Process: A Step-by-Step Guide
At its core, verifying a Stripe webhook involves comparing a signature you compute with the signature Stripe sent in the Stripe-Signature header. If they match, and the timestamp is recent enough, you can trust the webhook.
Here's the detailed process:
Step 1: Extract the Timestamp and Signature(s)
Parse the Stripe-Signature header. You'll need to extract the value for t and all v1 values. Store them separately.
Step 2: Prepare the Signed Payload
This is a critical step where even a single extra space can break verification. You need to construct a string that exactly matches what Stripe signed. This string consists of:
- The timestamp (
t) you extracted. - A literal dot (
.). - The raw, unmodified HTTP request body.
Concatenated, it looks like this: t.request_body.
Crucial detail: You must use the raw request body. If your server framework automatically parses the JSON body before you access it, and then you try to stringify it again, you risk introducing subtle changes (like whitespace differences, key order changes, or Unicode encoding variations) that will cause the signature to mismatch.
Step 3: Compute the Expected Signature
Now, you'll generate your own HMAC-SHA256 signature using:
- The signing secret: This is your unique webhook secret, which you can find in your Stripe Dashboard under "Developers > Webhooks" for each specific endpoint. It looks like
whsec_.... - The signed payload: The string you prepared in Step 2.
The algorithm is HMAC-SHA256.
expected_signature = HMAC-SHA256(signing_secret, signed_payload)
The output of this operation will be a hexadecimal string.
Step 4: Compare Signatures
Compare the expected_signature you computed with each of the v1 signatures extracted from the Stripe-Signature header. If at least one of them matches, then the signature verification passes. Use a constant-time string comparison to prevent timing attacks.
Step 5: Check for Replay Attacks (Timestamp Verification)
Even if the signature matches, an old timestamp indicates a potential replay attack. Compare the extracted timestamp (t) with your server's current time. If the difference is greater than a small tolerance (e.g., 5 minutes), reject the event. This prevents an attacker from intercepting a legitimate webhook and resending it much later.
Real-World Example: Python Implementation
Let's look at a concrete example using Python. This snippet demonstrates how to perform the verification manually, highlighting the raw body requirement.
```python import hmac import hashlib import time import json import os # For webhook secret
--- Configuration ---
You'd get this from your Stripe Dashboard for the specific webhook endpoint
WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET") TOLERANCE_SECONDS = 300 # 5 minutes
def verify_stripe_signature(payload: bytes, signature_header: str, secret: str) -> bool: if not signature_header or not secret: print("Missing signature header or secret.") return False
# 1. Extract timestamp and signatures
try:
parts = signature_header.split(',')
timestamp = None
signatures = []
for part in parts:
key, value = part.split('=', 1)
if key == 't':
timestamp = int(value)
elif key == 'v1':
signatures.append(value)
if timestamp is None or not signatures:
print("Invalid Stripe-Signature header format.")
return False
except ValueError as e:
print(f"Error parsing Stripe-Signature header: {e}")
return False
# 2. Check timestamp for replay attacks
current_time = int(time.time())
if abs(current_time - timestamp) > TOLERANCE_SECONDS:
print(f"Webhook timestamp too old or too far in future. Timestamp: {timestamp}, Current: {current_time}")
return False
# 3. Prepare the signed payload
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
# 4. Compute the expected signature
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()