Stripe Checkout Price Tampering Vulnerability
Contents
TL;DR
Impact Implementations where the Checkout Session creation API takes amount or priceId from the client and passes them straight to unit_amount or line_items.price
Fix Accept only a product key like plan from the browser. Look up the Stripe Price ID and amount from a server-side allowlist
Test Copy the Checkout creation request via curl from the pricing page, tamper with amount, priceId, and quantity, then send. If a Checkout URL comes back at the tampered price, you’re vulnerable
DEV Community published Never trust the client with your Stripe price.
The point is simple: don’t write amount: req.body.amount in your Stripe Checkout creation endpoint.
This isn’t a Stripe-specific hole so much as a question of where you place payment authority.
I previously wrote about server-side verification of Google Play purchase tokens, and Stripe Checkout needs the same boundary.
A working Checkout is already dangerous
The dangerous pattern looks like this:
export async function POST(req: Request) {
const { amount, plan } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "eur",
product_data: { name: plan },
unit_amount: amount,
},
quantity: 1,
},
],
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
});
return Response.json({ url: session.url });
}
This code works fine.
Test card payments go through.
Successful charges show up in the Stripe Dashboard.
But if someone rewrites the request body with DevTools or curl, amount can be anything they want.
If the Premium button sends { "amount": 2999, "plan": "Premium" }, an attacker just changes it to { "amount": 100, "plan": "Premium" }.
Stripe doesn’t know whether Premium really costs 29.99 EUR for your service.
Stripe processes whatever amount the server hands it.
If your webhook or success page grants Premium access after payment, your app treats the cheap payment as a legitimate Premium purchase.
Accept only a product key, not the price
The most the browser should send is which product the user selected.
The amount, Stripe Price ID, product name, and entitlements are all decided server-side.
const PLANS = {
hobby: {
priceId: process.env.STRIPE_PRICE_HOBBY,
entitlement: "hobby",
},
premium: {
priceId: process.env.STRIPE_PRICE_PREMIUM,
entitlement: "premium",
},
enterprise: {
priceId: process.env.STRIPE_PRICE_ENTERPRISE,
entitlement: "enterprise",
},
} as const;
type PlanKey = keyof typeof PLANS;
function isPlanKey(value: unknown): value is PlanKey {
return typeof value === "string" && Object.hasOwn(PLANS, value);
}
export async function POST(req: Request) {
const body = await req.json();
if (!isPlanKey(body.plan)) {
return new Response("Invalid plan", { status: 400 });
}
const plan = PLANS[body.plan];
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
metadata: {
plan: body.plan,
entitlement: plan.entitlement,
},
});
return Response.json({ url: session.url });
}
With this shape, even if an attacker injects amount, the server never reads it.
Sending priceId is also ignored.
A value like plan: "free_lifetime" gets rejected by the allowlist.
Stripe’s official docs also have you create PaymentIntents on the server and hand the client only a client_secret.
For Checkout Sessions, you can use both line_items.price and line_items.price_data, but for fixed SaaS plans, referencing Price objects created in Stripe from the server is the cleanest approach.
Accepting just the priceId is still weak
Swapping amount for priceId doesn’t make it safe.
const { priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
});
This lets someone swap the Hobby plan’s price_xxx_5eur with the Enterprise plan’s price_xxx_500eur.
Stripe Price IDs are not secrets.
They can turn up in the pricing page’s HTML, JavaScript bundles, the Network tab, or URLs around past Checkouts.
It’s the same story as Supabase’s Anon Key being visible but protected by RLS—a value being visible to the frontend and that value carrying authority are separate concerns.
Stripe’s priceId may be visible for display or selection purposes.
But which priceId is used to create a Checkout should be a server-side authorization decision.
Dynamic prices still get calculated on the server
Donations, usage-based billing, quotes, and marketplaces do need prices that aren’t fixed Price objects.
Even then, don’t blindly trust unit_amount from the request body.
What the user is allowed to input is the raw material for calculating the price, not the price itself.
For donations, for example, enforce minimum and maximum amounts on the server.
export async function POST(req: Request) {
const { donationCents } = await req.json();
if (
typeof donationCents !== "number" ||
!Number.isInteger(donationCents) ||
donationCents < 100 ||
donationCents > 100000
) {
return new Response("Invalid amount", { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "eur",
product_data: { name: "Donation" },
unit_amount: donationCents,
},
quantity: 1,
},
],
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
});
return Response.json({ url: session.url });
}
For e-commerce, accept a product ID and quantity, then look up the price from the database.
For coupons, verify server-side that the code is valid, usable by that user, and applicable to that product.
Reject quantity: -1 and other extreme values too.
The dangerous pattern is passing browser-supplied values straight to Stripe.
Values sent to Stripe should come from the server’s database, config, authenticated sessions, and validated inputs.
Re-check entitlement conditions in the webhook
Even if you protect the price when creating the Checkout Session, sloppy entitlement logic opens a different hole.
Here’s the minimum to verify in the post-payment webhook:
| Value | Why |
|---|---|
event.type | Only process expected events like checkout.session.completed |
session.payment_status | Don’t grant entitlements on unpaid Sessions |
session.customer | Confirm it’s the Stripe Customer tied to the logged-in user |
session.metadata.plan | Use only the plan info the server put there |
line_items Price ID | Confirm the Price ID matches the entitlement being granted |
Accepting customerId from the client is especially dangerous.
Look up the Stripe Customer server-side from the logged-in user, then pass it to the Checkout Session.
”Whose payment is this” is the same class of problem as “how much was this payment”—don’t let the request body be the authority.
Hit your own code with curl
Whether your implementation is vulnerable is easy to check: copy the Checkout creation request from the pricing page’s Network tab.
curl -X POST https://example.com/api/checkout \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{
"plan": "premium",
"priceId": "price_FAKE",
"amount": 1,
"quantity": -1
}'
If a Stripe Checkout URL comes back and the Checkout page shows the tampered price, you’re exposed.
If the URL comes back but the displayed price is the real Premium price, you’ve got one layer of defense.
To go further, don’t just fire test events with the Stripe CLI—run an actual test payment and trace how your app’s user entitlements table gets updated.
Stripe’s payment success and your service’s entitlement grant are separate systems.
Conflate them and you end up with a Dashboard full of successful charges while your app is minting discount Premiums.