Verify Stripe webhook signature headers, replay defense, rotation
Minimal correct verification for Stripe: exact headers and signing inputs, replay protection, and a safe secret rotation workflow.
Includes runnable Node + Python snippets.
No credit card required. See pricing.
TL;DR
- Verify Stripe authenticity before parsing or side effects.
- Verify over the raw request body bytes; avoid JSON re-stringify.
- Required headers: "stripe-signature".
- Enforce a timestamp max-age window (300s) to reduce replay risk.
- Rotate secrets without downtime: accept {current, previous} during overlap, then retire.
Debugging auth failures? Use the webhook debugging playbook .
Anti-patterns
- Parsing/transforming the body before verification (breaks signing inputs).
- Returning 2xx before authenticity is proven (you just acknowledged an attacker).
- Skipping replay protection (signed requests can still be replayed).
If verification fails in production, capture the raw body + headers and follow the debugging playbook .
Core concepts
Secure webhooks by verifying authenticity deterministically: exact headers, exact signing input, and no side effects until verification passes.
Threat model (start here)
Forgery, replay, leaked secrets/tokens, and tenant confusion are the common webhook incidents. Design verification and secret lookup to fail closed.
Exact verification inputs
This strategy verifies HMAC-SHA256 (hex) using "t.payload" where t is from stripe-signature. Verify before parsing or side effects.
Replay + rotation
Enforce a timestamp max-age window (300s) and still dedupe for safety. Rotate secrets with an overlap window (current + previous).
Required headers (from the ingest strategy)
Confirm these are present before debugging signature math.
- Hooque parses `stripe-signature` for `t` and `v1`.
Need workflows around tunnels/replay? See local development (stripe) and retries & backoff (stripe) .
Normal flow vs With Hooque flow
The goal is the same either way: authenticate first, then make retries/replays harmless with idempotency.
Normal security flow
- Request hits your endpoint.
- Verify signature/token before parsing or side effects.
- Enforce timestamp max-age + dedupe window.
- Rotate secrets with overlap (current + previous).
- If your endpoint is down (deploy/offline/sleep), deliveries fail and you rely on retries/replays.
With Hooque
- Provider → Hooque ingest (verification at ingest time).
- Only verified requests are queued.
- Workers pull/stream events and ack/nack/reject explicitly.
- UI provides inspection + controlled redelivery after fixes.
- Ingest stays online when your worker is offline; events queue until you resume.
Production checklist
Use this when shipping Stripe webhook verification (and when debugging signature failures).
- [ ] HTTPS only; reject plain HTTP
- [ ] Body size limit + content-type allowlist (prefer strict in production)
- [ ] Capture the raw request body bytes for verification
- [ ] Verify required headers: `stripe-signature`
- [ ] Verify signature/token before parsing or side effects
- [ ] Constant-time compare for signatures/tokens (avoid timing leaks)
- [ ] Timestamp max-age check (tolerance window) + clock skew handling
- [ ] Dedupe on a stable key (provider event ID when available, otherwise hash of signature+body)
- [ ] Secret storage in a secret manager/KMS (avoid hardcoding)
- [ ] Secret rotation: accept `{current, previous}` during overlap, then retire
- [ ] Multi-tenant safety: scope secrets to the webhook/tenant identity
- [ ] Structured logs for verification failures (never log secrets)
- [ ] Alert on spikes in auth failures (misconfig or attack) Reference implementation
Minimal correct verification, replay protection, and secret rotation (Node + Python).
1) Minimal verification
Verify authenticity before parsing or side effects.
Node
Express raw body + verification
// Minimal Stripe verification (Express raw body)
// npm i express
import crypto from "crypto";
import express from "express";
const app = express();
app.post("/webhook", express.raw({ type: "*/*", limit: "2mb" }), (req, res) => {
const secret = process.env.WEBHOOK_SECRET ?? "whsec_replace_me";
const sigHeader = req.header("stripe-signature") ?? "";
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
const payload = rawBody.toString("utf8");
if (!sigHeader) return res.status(401).send("missing stripe-signature");
// Parse: t=123,v1=sig,v0=sig...
const parts = sigHeader.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const v1 = parts.find(([k]) => k === "v1")?.[1];
if (!t || !v1) return res.status(401).send("invalid stripe-signature");
// Timestamp tolerance (300s in Hooque strategy)
const tolerance = 300;
const timestamp = Number.parseInt(t, 10);
const now = Math.floor(Date.now() / 1000);
if (Number.isNaN(timestamp)) return res.status(401).send("invalid timestamp");
if (now - timestamp > tolerance) return res.status(401).send("stale timestamp");
const signedPayload = `${t}.${payload}`;
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
if (!timingSafeEqualString(v1, expected)) return res.status(401).send("bad signature");
return res.sendStatus(202);
});
app.listen(3000, () => console.log("listening on :3000"));
function timingSafeEqualString(a, b) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
} Python
FastAPI raw body + verification
# Minimal Stripe verification (FastAPI raw body)
# pip install fastapi uvicorn
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request) -> Response:
secret = (os.getenv("WEBHOOK_SECRET") or "whsec_replace_me").encode("utf-8")
sig_header = request.headers.get("stripe-signature", "")
raw_body = await request.body()
payload = raw_body.decode("utf-8")
if not sig_header:
return Response("missing stripe-signature", status_code=401)
# Parse: t=123,v1=sig,v0=sig...
items = [p.strip().split("=", 1) for p in sig_header.split(",")]
t = next((v for (k, v) in items if k == "t"), None)
v1 = next((v for (k, v) in items if k == "v1"), None)
if not t or not v1:
return Response("invalid stripe-signature", status_code=401)
tolerance = 300
try:
ts = int(t)
except Exception:
return Response("invalid timestamp", status_code=401)
now = int(time.time())
if now - ts > tolerance:
return Response("stale timestamp", status_code=401)
signed_payload = f"{t}.{payload}".encode("utf-8")
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
return Response("bad signature", status_code=401)
return Response(status_code=202) 2) Replay protection
Use timestamp max-age when available, plus a dedupe window so retries and replays are harmless.
Node
Timestamp max-age + dedupe window
// Stripe replay protection: timestamp tolerance + dedupe window (in-memory example)
import crypto from "crypto";
const seen = new Map(); // key -> expiresAtMs
function parseStripeSignature(sigHeader) {
const parts = sigHeader.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const v1 = parts.find(([k]) => k === "v1")?.[1];
return { t, v1 };
}
function purgeSeen() {
const now = Date.now();
for (const [key, expiresAt] of seen.entries()) if (expiresAt <= now) seen.delete(key);
}
// Inside your verified webhook handler:
purgeSeen();
const sigHeader = req.header("stripe-signature") ?? "";
const { t, v1 } = parseStripeSignature(sigHeader);
if (!t || !v1) return res.status(401).send("invalid stripe-signature");
const tolerance = 300;
const nowSeconds = Math.floor(Date.now() / 1000);
const ts = Number.parseInt(t, 10);
if (Number.isNaN(ts)) return res.status(401).send("invalid timestamp");
if (nowSeconds - ts > tolerance) return res.status(401).send("stale timestamp");
// Prefer Stripe event IDs when you have them. Otherwise hash v1 + raw body.
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
const key = crypto.createHash("sha256").update(v1).update(".").update(rawBody).digest("hex");
const ttlMs = 10 * 60 * 1000;
if (seen.has(key)) return res.status(409).send("replay detected");
seen.set(key, Date.now() + ttlMs); Python
Timestamp max-age + dedupe window
# Stripe replay protection: timestamp tolerance + dedupe window (in-memory example)
import hashlib
import time
seen: dict[str, float] = {} # key -> expires_at_epoch_seconds
def parse_stripe_signature(sig_header: str) -> tuple[str | None, str | None]:
items = [p.strip().split("=", 1) for p in sig_header.split(",")]
t = next((v for (k, v) in items if k == "t"), None)
v1 = next((v for (k, v) in items if k == "v1"), None)
return t, v1
def purge_seen() -> None:
now = time.time()
for k, exp in list(seen.items()):
if exp <= now:
del seen[k]
# Inside your verified webhook handler:
purge_seen()
sig_header = request.headers.get("stripe-signature", "")
t, v1 = parse_stripe_signature(sig_header)
if not t or not v1:
return Response("invalid stripe-signature", status_code=401)
tolerance = 300
now = int(time.time())
try:
ts = int(t)
except Exception:
return Response("invalid timestamp", status_code=401)
if now - ts > tolerance:
return Response("stale timestamp", status_code=401)
raw_body = await request.body()
key = hashlib.sha256((v1 + ".").encode("utf-8") + raw_body).hexdigest()
ttl_seconds = 10 * 60
if key in seen:
return Response("replay detected", status_code=409)
seen[key] = time.time() + ttl_seconds 3) Secret rotation (current + previous overlap)
Accept two secrets during an overlap window, then retire the previous secret.
Node
Try both secrets during overlap
// Stripe secret rotation: try {current, previous} during overlap
import crypto from "crypto";
function timingSafeEqualString(a, b) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
function verifyStripeWithSecrets(payload, stripeSignatureHeader, secrets) {
const parts = stripeSignatureHeader.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const v1 = parts.find(([k]) => k === "v1")?.[1];
if (!t || !v1) return false;
const signedPayload = `${t}.${payload}`;
for (const secret of secrets) {
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
if (timingSafeEqualString(v1, expected)) return true;
}
return false;
}
const secrets = [process.env.STRIPE_SECRET_CURRENT, process.env.STRIPE_SECRET_PREVIOUS].filter(Boolean); Python
Try both secrets during overlap
# Stripe secret rotation: try {current, previous} during overlap
import hashlib
import hmac
import os
def verify_stripe_with_secrets(payload: str, stripe_signature_header: str, secrets: list[bytes]) -> bool:
items = [p.strip().split("=", 1) for p in stripe_signature_header.split(",")]
t = next((v for (k, v) in items if k == "t"), None)
v1 = next((v for (k, v) in items if k == "v1"), None)
if not t or not v1:
return False
signed_payload = f"{t}.{payload}".encode("utf-8")
for secret in secrets:
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, v1):
return True
return False
secrets = [os.getenv("STRIPE_SECRET_CURRENT"), os.getenv("STRIPE_SECRET_PREVIOUS")]
secrets = [s.encode("utf-8") for s in secrets if s] For idempotency patterns and safe backoff policies, read Stripe retries & backoff .
Common failure modes
Most security failures are configuration drift: wrong secrets, missing headers, or verifying the wrong bytes.
All Stripe webhooks start failing verification
Likely causes
- Wrong secret/token configured (test vs prod mismatch).
- Missing/renamed headers (stripe-signature).
- Body parsing/middleware changed the signing input.
Next checks
- Confirm the secret value and environment match the sender.
- Log which headers are present (names only) and verify casing/normalization.
- Verify over raw bytes and ensure no transformation before verification.
Valid signatures are rejected as “stale”
Likely causes
- Clock skew between servers.
- Timestamp parsing issues (string vs int).
- Too-strict tolerance window.
Next checks
- Compare sender timestamp to server time; allow small skew.
- Ensure you parse timestamps as integers and validate safely.
- Use a max-age window and add dedupe as a second layer.
Duplicate deliveries cause duplicate side effects
Likely causes
- No dedupe store/TTL window.
- Handler is not idempotent.
- Retries/replays treated as new events.
Next checks
- Choose a stable dedupe key and store it with TTL.
- Make side effects idempotent (unique constraints, idempotency keys).
- Use a queue/worker model and explicit retry controls.
How Hooque helps
Verify at ingest and only process verified events — then use the queue to make retries/replays safe and auditable.
- Hosted ingest for Stripe with strategy-specific verification at ingest time.
- Only verified events enter the queue; unverified requests never reach your workers.
- Durable queue decouples ingest from processing (burst protection).
- Inspection + controlled redelivery after fixes (with an audit trail).
- Strategy-specific docs: local dev (stripe) and retries & backoff (stripe).
Start with signup, review pricing, and keep the debugging playbook handy for incidents.
FAQ
Short answers to the most common verification and incident-response questions.
Do I need the raw request body to verify Stripe webhook signatures?
General: Yes — signature schemes are computed over the exact bytes sent. Parsing JSON and re-stringifying can change whitespace/ordering and break verification. (stripe-signature) How Hooque helps: Hooque verifies at ingest before your app code runs, so you avoid framework-specific body parsing pitfalls.
What’s a webhook replay attack and how do I prevent it?
General: A replay attack is when a valid, signed request is captured and sent again. Use a timestamp max-age when available and store a dedupe key (event ID or a hash) for a TTL window. How Hooque helps: Only verified requests enter the queue, and you can implement dedupe/idempotency in workers while using UI redelivery for safe replays.
How do I rotate webhook secrets without downtime?
General: Accept `{current, previous}` secrets during an overlap window, verify against both, then retire the previous secret. Rotate per environment and per tenant. How Hooque helps: You can update verification secrets in one place (ingest) and keep workers stable while rotations happen.
Should I rely on IP allowlists to secure webhooks?
General: IP allowlists are brittle (IPs change; shared infrastructure). Prefer cryptographic verification (HMAC/public key) as the primary control, and use HTTPS + rate limits as defense-in-depth. How Hooque helps: Ingest-time verification reduces the attack surface of your own infrastructure and keeps unverified traffic out of your worker systems.
What status code should I return for invalid signatures?
General: Use `401`/`403` for authentication failures and avoid returning `2xx` before authenticity is proven. How Hooque helps: Hooque can reject invalid requests at ingest so only verified events are available to consumers.
How do I avoid tenant confusion in a multi-tenant webhook endpoint?
General: Bind secret lookup to the webhook identity/tenant and never “try secrets until something matches” across tenants. Scope verification to the correct tenant first. How Hooque helps: Webhooks are isolated per workspace/webhook, so verification config is naturally scoped and auditable.
Why does signature verification fail only in production?
General: Common causes are wrong secrets (test vs prod), proxies/middleware changing the body, header normalization differences, and clock skew for timestamp-based schemes. How Hooque helps: A consistent ingest layer reduces environment differences, and the dashboard makes it easier to inspect failed deliveries and debug safely.
Start processing webhooks reliably
Verify at ingest, queue durably, and consume with explicit ack/nack/reject — without building custom webhook infrastructure.
No credit card required