Payment amounts sourced server-side, never from client
Why it matters
AI scaffolding routinely generates /api/checkout handlers that accept amount, price, or priceId from the client request body and pass the value straight into stripe.checkout.sessions.create(...), paddle.transactions.create(...), lemonSqueezy.checkouts.create(...), polar.checkouts.create(...), or braintree.transaction.sale(...). Any buyer can open browser dev tools, rewrite the outbound request, and pay $0.01 for a $999 product — the handler dutifully forwards the tampered number to the processor, which has no idea the merchant's intended price was different. This pattern is one of the most common indie-SaaS exploits reported on Twitter/X and in Shopify-app vulnerability disclosures since 2019; Stripe's own Checkout.sessions.create documentation opens with an explicit warning against it. The fix is trivial (look up the price server-side from a catalog or validated allowlist), but the LLM default — "accept what the client sent, pass it through" — produces the broken shape every time unless the prompt specifies server-side derivation. Once the site has real traffic, automated scrapers find mispriced checkouts within days.
Severity rationale
Critical because every successful exploit is a direct revenue loss that compounds per sale, and attackers can script unlimited $0.01 purchases across the entire catalog in minutes.
Remediation
Look up the authoritative price server-side from a catalog — a hardcoded constant map, environment variable, or database row keyed by the product slug — and pass that value to the payment processor's .create() call. If the client sends a priceId, validate it against a server-held allowlist (const ALLOWED_PRICE_IDS = new Set(['price_abc', 'price_def'])) before use. Never let req.body.amount, req.body.price, or an unvalidated req.body.priceId reach the processor.
Deeper remediation guidance and cross-reference coverage for this check lives in the payment-security and api-security Pro audits — run those after applying this fix for a more exhaustive pass on the same topic.
Detection
- ID:
payment-amount-server-side-only - Severity:
critical - What to look for: Enumerate every handler in
app/api/**,pages/api/**,server/**, or equivalent that calls a payment-processor creation method:stripe.checkout.sessions.create(...),stripe.paymentIntents.create(...),stripe.subscriptions.create(...),paddle.transactions.create(...),lemonSqueezy.checkouts.create(...)(orcreateCheckout),polar.checkouts.create(...),braintree.transaction.sale(...). For each call, trace theamount,unit_amount,price,priceId, orline_items[].priceargument back to its source. PASS when the value comes from a server-side constant, environment variable, database lookup keyed by product slug, or apriceIdvalidated against a server-held allowlist. FAIL when the value comes fromreq.body.amount,req.body.price,req.body.priceId(without allowlist check),params.amount,searchParams.get('amount'), or any other client-controllable input. - Pass criteria: Every payment-creation call sources its amount/price from a server-controlled value, or validates an incoming
priceIdagainst a server-held allowlist before use. - Fail criteria: One or more payment-creation calls where the amount, unit_amount, price, or unvalidated priceId comes from the request body, URL params, or search params.
- Skip (N/A) when:
package.jsoncontains none ofstripe,@paddle/*,@lemonsqueezy/*,@polar-sh/*, orbraintree, and no other payment-processor library is detected. Report the package-json lookup evidence. - Do NOT pass when: A handler destructures
amountfromreq.bodyand passes it to the processor, even if "validated" with a non-negative check — a non-negative number is still attacker-controlled. - Before evaluating, quote: Quote the file path and the line invoking the processor creation method for every match. If zero payment-creation calls exist, report
"0 payment-processor create() calls found across N handler files". - Report even on pass: Report the count of payment-creation call sites inspected and the source type for each:
"Inspected X create() call sites; all sourced from server-side catalog / allowlisted priceId". - Detail on fail:
"Client-controlled amount in app/api/checkout/route.ts — req.body.amount passed directly to stripe.checkout.sessions.create()"— name the file, the processor method, and the client-source field. - Remediation: Look up the authoritative price server-side from a catalog — a hardcoded constant map, environment variable, or database row keyed by the product slug — and pass that value to the payment processor's
.create()call. If the client sends apriceId, validate it against a server-held allowlist before use. Never letreq.body.amount,req.body.price, or an unvalidatedreq.body.priceIdreach the processor.
External references
- cwe:4.14 · CWE-602 — Client-Side Enforcement of Server-Side Security
- pci-dss:4.0 · Req 6.5 — Input validation in payment applications
Taxons
History
- 2026-04-23·v1.0.0·Initial authoring for Stack Scan v3.1 abuse bucket·by phase-9-1-stack-scan-v3-1