---
title: "The client sets the price, role, or plan"
slug: client-sets-price-or-role
severity: critical
category: Payments
summary: "If the browser can send amount, plan, or role and the server saves it, users will send the values they want."
platforms:
  - Stripe
  - Next.js
  - Supabase
  - Firebase
updated: "2026-10-13"
related:
  - unverified-stripe-webhooks
  - server-actions-without-auth
  - idor
---

The client is a suggestion. A checkout request that includes `amount: 100` in cents, or `priceId` copied from a hidden field the user can edit, is the user choosing what they pay. A profile update that includes `role: "admin"` or `plan: "pro"` is the user choosing their privileges.

The server should look up the price from a list you control. In Stripe, that list is a Price you created in the Dashboard, referenced by id. Your server decides which price id is allowed for this action. The webhook, after signature verification, is what marks the account paid. The success URL is a page you show. It is not proof of payment. See [unverified Stripe webhooks](/issues/unverified-stripe-webhooks).

Mass assignment is the general name for "the update copies the JSON onto the row." ORMs will happily set `role` if you pass `...req.body`.

## How to check in 5 minutes

1. Search for `amount`, `price`, `plan`, `role`, and `isAdmin` in Route Handlers and Server Actions. If the value comes from `request.json()` or `formData` and is written to Stripe or the database, that is the bug.
2. Create a checkout on a test account and change the amount in the request before it is sent. If the Stripe session opens for the edited amount, the server trusted you.
3. Update your own user row with an extra `role` field. Read it back. If it stuck, the update is not filtering fields.
4. Load the post-checkout success page while logged in as someone who has not paid. If it grants the plan, the page is the bug. The webhook should be the grant.

## How to fix

### Stripe

```ts
const prices = {
  pro: process.env.STRIPE_PRICE_PRO!,
} as const;

export async function POST() {
  const user = await getCurrentUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: prices.pro, quantity: 1 }],
    success_url: "https://your-app.example/account",
    cancel_url: "https://your-app.example/pricing",
    client_reference_id: user.id,
  });
  return Response.json({ url: session.url });
}
```

The price id lives in the server environment or a server-side map. Quantity, if the user may choose it, is checked against a range you set. The webhook handler, after `constructEvent`, writes the plan. The success page only displays status it reads from your database.

### Next.js

Assign fields explicitly:

```ts
await db.user.update({
  where: { id: user.id },
  data: { name: form.name },
});
```

Do not `data: await request.json()`.

### Supabase

A policy `with check (auth.uid() = id)` still allows the user to set other columns on their own row. Revoke column updates they should not have, or use a trigger that rejects changes to `plan` and `role`. RLS that only checks the owner does not check the price.

### Firebase

```
allow update: if request.auth.uid == resource.data.userId
  && request.resource.data.role == resource.data.role
  && request.resource.data.plan == resource.data.plan;
```

Deny changes to privileged fields in rules. The Admin SDK can set them after a verified webhook.

## Agent prompt

```text
You are stopping the client from setting price, plan, or role. Do not add features.

1. Find checkout code. The amount or price must come from a server-side price id, not from the request body. Quantity must be range-checked if it comes from the client.
2. Find user updates. Replace object spreads of request JSON with an explicit field list that cannot include role, plan, isAdmin, or credits.
3. Grant paid plans only from a Stripe webhook whose signature you verified. Do not grant them from the success page or from a client call.
4. If Supabase or Firebase rules let a user update their own plan column, deny that column.

Show each write you narrowed and where a plan is now granted.
```

## FAQ

### We show the price in the page. Is that the bug?

No. Displaying a price is fine. Charging whatever number comes back from the browser is the bug.

### Can I trust a price id the client sends if price ids are not secret?

Only if the server checks that id against an allowlist for that action. A client can send a different price id that exists in your Stripe account, including a one-dollar price you made for a test.

## Sources

- [Stripe webhooks](https://docs.stripe.com/webhooks)
- [OWASP Mass Assignment Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html)
