Signing the checkout payload
Applies to The Fabrica v0.1.2. Documentation source:
1ee1bc9cb661, including post-release documentation corrections.
The competitor pattern
Other AI SaaS boilerplates open Paddle’s checkout overlay like this:
// client side
const { paddle_price_id } = await api.post("/checkout", { tier })
Paddle.Checkout.open({
items: [{ priceId: paddle_price_id }],
customer: { email: user.email },
customData: { clerk_user_id: user.id, tier },
})# server side, webhook handler
async def handle_paddle_webhook(payload):
user_id = payload["data"]["custom_data"]["clerk_user_id"]
user = await users.get(user_id)
# ... bind subscription to userThe webhook handler trusts whatever custom_data arrives. The frontend
puts it in, Paddle delivers it back, the server reads it. Three hops, one
of which (the frontend) is fully attacker-controlled.
The attack
Alice initiates checkout. Before clicking “Pay”, she opens DevTools and
swaps customData.clerk_user_id from her own ID to Bob’s. She completes
payment.
Paddle’s webhook fires with custom_data.clerk_user_id = bob. The
handler dutifully binds the subscription to Bob’s account. Bob now has
a tier he didn’t pay for. Alice waits 60 days, then disputes the
Paddle charge (“I didn’t authorise this”). Paddle refunds Alice. You
get the chargeback fee. Bob keeps the tier until you notice — could
be months.
Variant: Alice swaps the price_id for a cheaper tier. Mostly
self-defeating (she gets what she paid for), but creates support
confusion when the subscription state on your side doesn’t match
Paddle’s billing.
What we ship instead
When /checkout resolves a price_id, we HMAC-sign a payload binding
(user_id, email, price_id, issued_at) and return the signature:
# services/paddle.py
def sign_checkout_payload(*, user_id, email, price_id) -> str | None:
secret = settings.paddle_webhook_secret.get_secret_value().encode()
issued_at = int(datetime.now(UTC).timestamp())
msg = f"v1|{user_id}|{email.lower()}|{price_id}|{issued_at}".encode()
digest = hmac.new(secret, msg, hashlib.sha256).hexdigest()
return f"{issued_at}:{digest}"Frontend includes it in custom_data.thefabrica_sig:
Paddle.Checkout.open({
items: [{ priceId: checkout.paddle_price_id }],
customer: { email: user.email },
customData: {
clerk_user_id: user.id,
tier,
thefabrica_sig: checkout.checkout_signature, // ⬅ binding
},
})The webhook handler verifies before acting:
# routers/billing.py
sig = custom_data.get("thefabrica_sig")
if not verify_checkout_payload(
user_id=str(candidate.id),
email=candidate.email,
price_id=price_id,
signature=sig,
):
raise ConflictError("Paddle webhook custom_data signature verification failed.")Properties:
- Tampering fails closed. Alice can swap
clerk_user_idbut she can’t re-sign for Bob’s(user_id, email)— she doesn’t have thepaddle_webhook_secret. - 6-hour MAX_AGE. Old signatures (replay attacks, captured checkout intents) fail. Covers slow checkout flows + Paddle’s webhook retry window.
- 5-min future skew tolerance. Clock drift between API servers doesn’t false-reject.
- Email lowercased before hashing.
Alice@example.comsigns the same asalice@example.com. - Constant-time compare via
hmac.compare_digest. Timing-attack resistant.
The 3-strategy fallback
custom_data isn’t always present (legacy purchases, Paddle dashboard
manual adjustments, third-party integrations). We have a fallback chain:
- Signed
custom_data.clerk_user_id— verified, gold-standard. paddle_customer_id→ existing Subscription → User. Works for renewals + lifecycle events where the original purchaser is known.customer.emailcase-insensitive match. Last resort; logged as a fallback so you can monitor verification gaps.
If (1) was attempted (signature present) but failed, we hard-reject
— that’s an attack signal, not a missing-data case.
Trade-offs
- One more secret to rotate (we reuse
paddle_webhook_secretto avoid introducing a new one). - Frontend must pass the signature through. If a buyer skips it, the webhook handler degrades to fallback strategies 2 + 3.
- Signing fails gracefully when
paddle_webhook_secretis unset (dev mode):checkout_signatureis null, webhook handler uses fallbacks.
Receipts
sign_checkout_payload/verify_checkout_payload:backend/src/services/paddle.py- Webhook handler:
_find_user_from_webhook_datainbackend/src/api/routers/billing.py - Tests: 8 cases in
backend/tests/test_paddle_service.py(test_sign_then_verify_roundtrip,test_verify_rejects_tampered_*,test_verify_rejects_signature_older_than_6h, etc.)
Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).
Extracted from docs/recipes/why-checkout-signing.md.
That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.