SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Price and Plan Tampering at Checkout
CWE-472A01:2025 Broken Access ControlHigh severityStripeNext.jsSupabase

Price and Plan Tampering at Checkout

✓The kit resolves the price and the product server-side from config; the client only names a plan, so a cheap-price, expensive-plan swap is not possible.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

Stripe computes the charge amount from the Price object on its servers, so a client cannot pay an arbitrary number. The real risk is which price and which entitlement the client gets to choose. If your Server Action accepts a priceId or a product name from the request and trusts it, a user can pair a cheaper price with a more expensive plan. The fix: accept only an opaque plan key, resolve the Stripe price id and the entitlement server-side from your own config, and grant access from the verified price, never from a client-sent product name.

Where it shows up: A checkout Server Action passes a client-supplied priceId straight into line_items, or stores a client-supplied product name as the entitlement, instead of resolving both from a server-side allowlist.

The vulnerable patterns and their fixes

Client-chosen price id with no allowlist

✗Vulnerablets
// app/actions/billing.ts  (Server Action)
'use server'
import { z } from 'zod'

const checkoutSchema = z.object({
  priceId: z.string().min(1), // whatever the client sends
  productName: z.string().optional(),
})

export async function createCheckoutSession(data: unknown) {
  const { priceId } = checkoutSchema.parse(data)

  // priceId is forwarded as-is: any active price in the account is accepted
  const session = await getStripe().checkout.sessions.create({
    mode: 'payment',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: '...',
    cancel_url: '...',
  })
  redirect(session.url)
}

The schema validates that priceId is a non-empty string, not that it is one of your real plan prices. Zod proves shape, not authorization. The client picks the price.

↓the fix
✓Securets
// app/actions/billing.ts  (Server Action)
'use server'
import { z } from 'zod'
import config from '@/config'

// the client may only name a plan; the server owns the price id
const checkoutSchema = z.object({ plan: z.enum(['starter', 'pro']) })

const PRICE_BY_PLAN = {
  starter: config.billing.plans[0].priceId.monthly,
  pro: config.billing.plans[1].priceId.monthly,
}

export async function createCheckoutSession(data: unknown) {
  const { plan } = checkoutSchema.parse(data)
  const priceId = PRICE_BY_PLAN[plan] // resolved server-side, never trusted from input

  const session = await getStripe().checkout.sessions.create({
    mode: 'payment',
    line_items: [{ price: priceId, quantity: 1 }],
    metadata: { plan }, // entitlement keyed to the server-resolved plan
    success_url: '...',
    cancel_url: '...',
  })
  redirect(session.url)
}

The client now sends only a plan key from a closed enum. The server maps that key to the real price id in config.ts, so an unknown or cheaper price can never reach Stripe, and the entitlement in metadata is the server-resolved plan, not a client label.

Entitlement derived from a client-sent product name

✗Vulnerablets
// the webhook grants access from a client-controlled label
case 'checkout.session.completed': {
  const session = event.data.object
  await admin.from('purchases').insert({
    user_id: session.metadata?.user_id ?? '',
    product_id: session.metadata?.product_id, // came from the client
    amount: session.amount_total ?? 0,
    status: 'completed',
  })
  await sendPurchaseDeliveryEmail(
    session.customer_details?.email,
    session.metadata?.product_id, // delivers whatever the client asked for
  )
  break
}

The product, and therefore what gets delivered, comes from metadata the client set at checkout. A cheap price plus an expensive product name yields the expensive delivery.

↓the fix
✓Securets
// derive the plan from the verified price, not the client label
case 'checkout.session.completed': {
  const session = event.data.object

  // expand line items and map the real, charged price back to a plan
  const full = await getStripe().checkout.sessions.retrieve(session.id, {
    expand: ['line_items'],
  })
  const chargedPriceId = full.line_items?.data[0]?.price?.id
  const plan = planForPriceId(chargedPriceId) // your server-side lookup
  if (!plan) throw new Error('Unknown price on completed session')

  await admin.from('purchases').insert({
    user_id: session.metadata?.user_id ?? '',
    product_id: plan,                 // from the verified price
    amount: session.amount_total ?? 0,
    status: 'completed',
  })
  await sendPurchaseDeliveryEmail(session.customer_details?.email, plan)
  break
}

Fulfilment now flows from the price Stripe actually charged. Even if a client sets a misleading metadata label, the entitlement is computed from the verified line item, so the money paid and the access granted can no longer diverge.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

Open the network tab on any checkout button and you can see the request body the Server Action receives. In a kit like this one, that body carries a priceId and a productName. Both are editable.

Classic amount tampering does not work here, and that trips people into thinking the flow is safe. Stripe looks up the amount from the Price object you reference, so sending a body that says "charge me 1 dollar" is ignored. What the client does control is the choice of price and the label that becomes the entitlement.

Two real consequences follow. First, with no allowlist, the action forwards whatever priceId arrives, so a user can select any active price in your Stripe account: a different product's price, an old promotional price, or a test price. Second, and worse, the entitlement is granted from productName. The checkout sets the order's product from that client value, and the webhook reads it back to decide what to deliver. So a user checks out at the cheaper Starter price while sending the product name for Pro, pays the Starter amount, and receives the Pro entitlement. No amount was forged. The price was real. The mapping from money to access was simply handed to the client.

How to find it in your code

Start at the checkout Server Action and ask one question: where does the price come from? If the input schema contains a priceId or price field, the client is choosing the price.

grep -rn "priceId\|line_items" app actions lib

A safe action takes a plan key and resolves the price id from config.ts server-side. An unsafe one forwards a price id from the request.

Then trace the entitlement. Find where fulfilment decides what to deliver, and check whether it reads a client-set value or the charged price:

grep -rn "metadata" app/api/webhooks

If the delivered product comes from session.metadata.product_id and that value originated on the client, the money-to-access mapping is client-controlled. Derive it from the expanded line item's price instead.

Common mistakes

  • Myth“Stripe validates the price, so checkout cannot be tampered with.”

    Stripe validates the amount against the Price object, which stops you from paying an arbitrary number. It does not stop the client from choosing which price or which plan, which is the actual tampering vector.

  • Myth“The priceId is set in my frontend code, so users cannot change it.”

    Anything the frontend sends is in the request body and fully editable in dev tools or a curl command. A value being hard-coded in your client bundle gives it no integrity at the server.

  • Myth“I check that amount_total is correct in the webhook.”

    A correct amount for a cheap price is still a cheap price. If the entitlement is granted from a client label rather than the charged price, the amount check passes while the user gets more than they paid for.

  • Myth“metadata is set by my server, so it is trusted.”

    It is only trusted if the value originated on the server. Echoing a client-sent product name into metadata launders untrusted input into a field that looks server-owned.

Does SecureStartKit prevent this?

SecureStartKit closes this by default. `createCheckoutSession` in `actions/billing.ts` accepts only a plan key, looks it up in `config.billing.plans`, and takes both the Stripe price id and the `product_id` from that one matched entry. The browser never sends a price id or a product label, so it cannot pair a cheap price with an expensive plan, and Stripe still computes the amount from the Price object. An unknown plan key is rejected before any Stripe call. If you ever automate plan-specific access, derive it from the charged price, and keep new plans in `config.ts` so the price and the entitlement always come from the same source.

Verify the webhook that fulfils the order→

Frequently asked questions

Can a user change the price in Stripe Checkout?
They cannot change the amount charged for a given price, because Stripe reads it from the Price object on its servers. They can change which price id your code sends to Stripe if your Server Action accepts one from the request, which lets them pick a different or cheaper price.
Is price tampering possible if I use Stripe Checkout?
Hosted Checkout removes amount tampering, but it does not remove plan or entitlement tampering. If the price id or the product label comes from the client, a user can still pair a cheap charge with an expensive plan.
How do I stop users from selecting a cheaper plan?
Accept only an opaque plan key from the client, map it to the real Stripe price id server-side from your config, and grant the entitlement from the price Stripe charged, read back from the expanded line item in the webhook.
Should I validate the priceId on the server?
Validate that it is one of your known plan prices, or better, do not accept a price id from the client at all. Take a plan key and resolve the price id yourself so an arbitrary id can never reach Stripe.

References

  • Stripe: Create a Checkout Session (API) ↗
  • Stripe: Price objects (API) ↗
  • CWE-472: External Control of Assumed-Immutable Web Parameter (MITRE) ↗
  • OWASP A01:2025 Broken Access Control ↗

Related weaknesses

  • Unverified Stripe Webhook SignatureThe webhook route handler calls req.json() and acts on event.type directly, without calling stripe.webhooks.constructEvent against the raw body and the signing secret in STRIPE_WEBHOOK_SECRET.
  • Duplicate Stripe Webhook Events (No Idempotency)A Stripe webhook handler performs side effects (insert a purchase, send an email, grant access) without checking whether that event id was already processed, and returns a non-2xx on duplicates.
  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.

Defined terms

  • Server Actions
  • Stripe Webhook Signature Verification
  • Backend-Only Data Access

Go deeper

  • How to Add Stripe Payments to Next.js Using Server Actions

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns