# Payment amounts sourced server-side, never from client

- **Pattern:** `ab-002622` (`project-snapshot.abuse.payment-amount-server-side-only`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-23
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002622
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002622)

## 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(...)` (or `createCheckout`), `polar.checkouts.create(...)`, `braintree.transaction.sale(...)`. For each call, trace the `amount`, `unit_amount`, `price`, `priceId`, or `line_items[].price` argument back to its source. PASS when the value comes from a server-side constant, environment variable, database lookup keyed by product slug, or a `priceId` validated against a server-held allowlist. FAIL when the value comes from `req.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 `priceId` against 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.json` contains none of `stripe`, `@paddle/*`, `@lemonsqueezy/*`, `@polar-sh/*`, or `braintree`, and no other payment-processor library is detected. Report the package-json lookup evidence.
- **Do NOT pass when:** A handler destructures `amount` from `req.body` and 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 a `priceId`, validate it against a server-held allowlist before use. Never let `req.body.amount`, `req.body.price`, or an unvalidated `req.body.priceId` reach the processor.

## External references

- cwe CWE-602
- pci-dss Req 6.5

Taxons: project-snapshot, abuse-resistance, regulatory-conformance

HTML version: https://auditbuffet.com/patterns/ab-002622
