HomeGuidesStripe price tampering and credit race conditions, fixed

Guide

Stripe price tampering and credit race conditions, fixed

Stop users from changing Stripe Checkout prices and from spending the same credits twice with parallel requests. Tested Postgres and Supabase fixes.

23 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

Your AI coding tool builds a "Buy 500 credits" button. The browser sends { amount: 1999, currency: 'usd', quantity: 1 } to an API route, and the route passes it to Stripe. Then it builds the feature that spends credits: read the balance, check it, call the AI model, write the new balance. Both work in every demo.

Both are also open to anyone who can edit a request. The first lets a user pick their own price. The second lets a user spend the same credits many times by sending requests in parallel. OWASP calls these business logic flaws: the code does what it was written to do, but the rules behind it can be broken. This guide shows what each bug looks like, the fixes, and what happened when we attacked both versions locally with parallel requests.

Checked and tested on October 20, 2026 with stripe 23.0.0 (against a local mock, no real Stripe calls), @supabase/supabase-js 2.118.0, PostgREST 16.5 (the API layer Supabase uses), and PostgreSQL 17.11. VibeLocked is independent and not affiliated with Stripe or Supabase.

The short version

  • The browser names a product. The server picks the price. Keep a server-side map from product ID to Stripe Price ID. Never read amount, unit_amount, currency, price, a discount, or a plan's Price ID from the request body.
  • Validate quantity as a whole number between 1 and your own limit.
  • Promotion codes go through Stripe. Use allow_promotion_codes, or look the code up on the server and pass its ID in discounts.
  • Grant credits from what Stripe says was paid, in a verified webhook, once per Checkout Session.
  • Spend credits in one atomic step: UPDATE ... WHERE credits >= cost RETURNING, or a Supabase function that locks the row with FOR UPDATE.
  • One-time things get a unique constraint: welcome bonuses, coupons, free trials, referral rewards.
  • Users never write their own balance. Revoke UPDATE on the credits table for anon and authenticated.
  • Use Stripe idempotency keys on calls that create or move money, so a double click does not do it twice.

Bug 1: the browser chooses the price

This is the route AI tools often write:

import type Stripe from 'stripe';

// What AI tools often write. Do not ship this: the browser picks the price.
export async function createCheckoutNaive(stripe: Stripe, body: any, appUrl: string) {
  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [
      {
        price_data: {
          currency: body.currency,
          unit_amount: body.amount,
          product_data: { name: body.name },
        },
        quantity: body.quantity,
      },
    ],
    success_url: `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${appUrl}/pricing`,
  });
  return session.url;
}

Nothing stops the browser from sending amount: 1. Stripe cannot tell a real price from a tampered one: price_data.unit_amount is just a number your server sends, and the Stripe SDK's own type docs describe it as "a non-negative integer in cents." When we called this function with { currency: 'usd', amount: 1, name: '500 credits', quantity: 1 }, the request that reached our Stripe mock contained line_items[0][price_data][unit_amount]=1.

The same mistake comes in several shapes:

  • Amount or currency from the body. The user picks the number, or a currency where the same number is worth less.
  • Price ID from the body. Price IDs are not secret. If your route accepts priceId, a user can send the ID of your cheapest plan or an old discounted price. All of them are real Prices in your account, so Stripe accepts them.
  • Quantity from the body. 0, -1, 1.5, "2", and 1000000000 are all values your code may not expect.
  • Discount from the body. A discountPercent or finalTotal field is a price field with another name.
  • Credits from the body. A route that grants metadata.credits or body.credits after payment lets the user decide how many they bought.
  • Pay-as-you-go with PaymentIntents. Same rule: compute amount on the server from your own data before calling stripe.paymentIntents.create.

OWASP's Business Logic Security Cheat Sheet puts it plainly: "Never accept a price, subtotal, tax, or total from the client. Accept product identifiers and quantities only." PortSwigger's logic flaw examples call the root cause "excessive trust in client-side controls," and MITRE files it as CWE-602, client-side enforcement of server-side security. Hiding the price field or disabling a button changes nothing: the request can be edited before it is sent.

The fix: the server owns the catalog

Create your products and prices in Stripe, then keep their Price IDs in server code. Stripe's products and prices guide describes inline prices as the case for "a custom price that hasn't been preconfigured"; most apps do not need them. If your prices live in your own database instead, look them up there by product ID, the same way.

import type Stripe from 'stripe';

// The only place prices come from. Price IDs are not secret.
const CATALOG = {
  credits_500: { priceId: 'price_1QcreditsFiveHundred', mode: 'payment', maxQuantity: 10 },
  pro_monthly: { priceId: 'price_1QproMonthly', mode: 'subscription', maxQuantity: 1 },
} as const satisfies Record<string, { priceId: string; mode: Stripe.Checkout.SessionCreateParams.Mode; maxQuantity: number }>;

type ProductId = keyof typeof CATALOG;

export class CheckoutError extends Error {
  status: number;
  constructor(status: number, message: string) {
    super(message);
    this.status = status;
  }
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PROMO = /^[A-Za-z0-9_-]{1,40}$/;

export async function createCheckout(stripe: Stripe, userId: string, body: unknown, appUrl: string) {
  const input = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>;

  // 1. The browser names a product. It never sends a price, amount, or currency.
  const productId = input.productId;
  if (typeof productId !== 'string' || !Object.hasOwn(CATALOG, productId)) {
    throw new CheckoutError(400, 'Unknown product');
  }
  const product = CATALOG[productId as ProductId];

  // 2. Quantity: a whole number inside your limits.
  const quantity = input.quantity ?? 1;
  if (!Number.isInteger(quantity) || (quantity as number) < 1 || (quantity as number) > product.maxQuantity) {
    throw new CheckoutError(400, 'Invalid quantity');
  }

  // 3. Discounts: look the code up in Stripe. Stripe enforces expiry, limits, and restrictions.
  let discounts: Stripe.Checkout.SessionCreateParams.Discount[] | undefined;
  if (input.promoCode !== undefined && input.promoCode !== '') {
    if (typeof input.promoCode !== 'string' || !PROMO.test(input.promoCode)) {
      throw new CheckoutError(400, 'Invalid promotion code');
    }
    const codes = await stripe.promotionCodes.list({ code: input.promoCode, active: true, limit: 1 });
    if (codes.data.length === 0) {
      throw new CheckoutError(400, 'Invalid promotion code');
    }
    discounts = [{ promotion_code: codes.data[0].id }];
  }

  // 4. One click, one session: the browser sends a fresh UUID per checkout attempt.
  const attemptId = input.attemptId;
  if (typeof attemptId !== 'string' || !UUID.test(attemptId)) {
    throw new CheckoutError(400, 'Missing attempt ID');
  }

  const session = await stripe.checkout.sessions.create(
    {
      mode: product.mode,
      line_items: [{ price: product.priceId, quantity: quantity as number }],
      discounts,
      client_reference_id: userId,
      metadata: { userId, productId },
      success_url: `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${appUrl}/pricing`,
    },
    { idempotencyKey: `checkout-${attemptId}` },
  );
  return session.url;
}

What each part does:

  1. Object.hasOwn is used instead of CATALOG[productId] so a product ID like __proto__ or constructor cannot reach a built-in object property.
  2. Number.isInteger rejects strings, decimals, true, and NaN. The limit comes from the catalog, so a monthly plan cannot be bought ten times in one session.
  3. A promotion code is only a string the user typed. Listing promotion codes with code and active: true returns the real promotion code, and Stripe enforces the limits you set on it (expiry, number of redemptions, eligible customers). The Stripe SDK notes that discounts currently accepts only one entry.
  4. The idempotency key is explained in its own section below.

client_reference_id and metadata are set from the signed-in user on the server, never from the body.

The route stays thin. It reads the user from the verified session (see getSession vs getUser and getClaims) and builds the redirect URLs from a configured APP_URL, not from request headers:

import Stripe from 'stripe';
import { createCheckout, CheckoutError } from '@/lib/checkout';
import { createClient } from '@/lib/supabase/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.APP_URL!; // for example https://yourapp.com

export async function POST(request: Request) {
  const supabase = await createClient();
  const { data } = await supabase.auth.getClaims();
  const userId = data?.claims.sub;
  if (!userId) {
    return Response.json({ error: 'Sign in first' }, { status: 401 });
  }

  try {
    const body = await request.json().catch(() => null);
    const url = await createCheckout(stripe, userId, body, APP_URL);
    return Response.json({ url });
  } catch (err) {
    if (err instanceof CheckoutError) {
      return Response.json({ error: err.message }, { status: err.status });
    }
    throw err;
  }
}

If your page does not need its own code field, there is an even simpler option. Stripe's discounts guide shows allow_promotion_codes: true, which adds a code field to Stripe's checkout page. The user then never sends a code to your server at all.

Grant what Stripe says was paid

The second half of the price bug is on the way back. Your webhook should not trust anything the browser could have influenced, including a credit count you put in metadata. Stripe's fulfillment guide says to retrieve the Checkout Session "with the line_items property expanded," check payment_status, and fulfill from those line items. It also warns that your fulfillment function "might be called multiple times, possibly concurrently, for the same Checkout Session."

So map the paid Price ID back to the credits it buys (price_1QcreditsFiveHundred to 500 per unit), multiply by the paid quantity, and record the Checkout Session ID with a unique constraint so the second call does nothing. The Stripe webhooks guide covers the signature check and the duplicate-event table, and the Stripe success page guide explains why the success page must not grant anything by itself.

Idempotency keys for the Stripe side

A double click, a retry after a timeout, or a user with two tabs can send the same "create" twice. Stripe's idempotent requests docs say a key lets you "safely repeat the request without risk of creating a second object or performing the update twice." The rules worth knowing:

  • Keys are up to 255 characters. Stripe suggests V4 UUIDs and says to avoid "sensitive data (for example, email addresses or personal identifiers)" in keys.
  • Stripe "compares incoming parameters to those of the original request and errors if they're not the same," so one key cannot be reused for a different purchase.
  • Keys can be removed "after they're at least 24 hours old," so they protect retries, not long-term uniqueness. Your database handles that.
  • The official Node library retries failed requests once by default and, per its README, adds idempotency keys "where appropriate." Your own key matters when your code or the user's browser is the one retrying.

For Checkout, a duplicate mostly means two open sessions, and only one gets paid. Keys matter most where money moves immediately: charging a saved card for a top-up, confirming a PaymentIntent, issuing a refund. Derive the key from the thing that must happen once, such as your own order ID, and send the same key on every retry of that operation.

What we tested

We ran the code above against a local HTTP server that stood in for Stripe, using the real stripe 23.0.0 Node library with a fake key, so nothing reached Stripe. All 32 checks passed:

  • The naive route forwarded unit_amount=1.
  • The fixed route always sent line_items[0][price] from the catalog. A body with amount: 1, currency: 'inr', price: 'price_cheap', and priceId: 'price_cheap' produced exactly the same Stripe request as a clean one.
  • Product IDs __proto__, constructor, toString, hasOwnProperty, an unknown plan, 123, and null were rejected with 400 before any Stripe call.
  • Quantities 0, -1, 1.5, "2", 11, 1000000000, and true were rejected; a missing quantity became 1; the monthly plan rejected quantity 2.
  • launch20 was looked up with active=true&limit=1 and applied as discounts[0][promotion_code]. An unknown code and a code sent as an object were rejected, and a malformed code was rejected without calling Stripe.
  • Every Checkout Session request carried an Idempotency-Key header, and two calls with the same attempt ID sent the same key.

The TypeScript, including the route, passes tsc --strict against the Stripe and supabase-js type definitions.

Bug 2: two requests spend the same credit

Here is the credits code AI tools often write with supabase-js:

// What AI tools often write. Do not ship this.
export async function spendCreditsNaive(
  supabase: SupabaseClient,
  userId: string,
  cost: number,
  runModel: () => Promise<string>,
) {
  const { data: ent } = await supabase
    .from('entitlements')
    .select('credits')
    .eq('user_id', userId)
    .single();

  if (!ent || ent.credits < cost) {
    return { status: 402, body: { error: 'Not enough credits' } };
  }

  const output = await runModel();

  await supabase
    .from('entitlements')
    .update({ credits: ent.credits - cost })
    .eq('user_id', userId);

  return { status: 200, body: { output } };
}

It reads the balance, checks it, does the paid work, and then writes ent.credits - cost. If 20 requests arrive together, all 20 read the same balance before any of them writes. They all pass the check, all 20 call the model, and they all write the same new value.

This is a time-of-check to time-of-use race (CWE-367). PortSwigger's race conditions guide calls the gap between the check and the write the "race window" and lists the usual targets: "Redeeming a gift card multiple times," applying a discount code more than once, "Withdrawing or transferring cash in excess of your account balance." Its Smashing the state machine research introduced the single-packet attack, which the guide says can "complete 20-30 requests simultaneously," so a small window is not a defense. The OWASP cheat sheet says it directly: "If the window exists at all, assume it's exploitable." Attackers do not need special tools for the simple version: an ordinary script that sends 20 requests at once is enough.

What we saw

We set up a local copy of the Supabase pieces that matter here: PostgreSQL 17 with the anon and authenticated roles, Supabase's default grants, auth.uid(), and PostgREST 16.5 in front, called through supabase-js with a signed-in user's token. The user started with 3 credits and sent 20 parallel requests that each cost 1 credit. We ran every case 5 times. Exact counts for the naive versions varied between runs; the table shows our final run.

Code Requests that succeeded Paid work done Balance after
Naive supabase-js read then write 20 of 20, every trial 20 2
Same, with a 300 ms model call between check and write 20 of 20, every trial 20 2
Read, then BEGIN; ... UPDATE ... COMMIT writing the value read 14 to 20 14 to 20 0 or 2
Read, then SET credits = credits - 1 with a CHECK (credits >= 0) 3, plus 17 errors 20 0
Fix 1: one conditional UPDATE 3 of 20, every trial 3 0
Fix 2: spend_credits() with FOR UPDATE 3 of 20, every trial 3 0
Fix 2, with a 300 ms model call 3 of 20, every trial 3 0

Three things stand out. First, the naive version did not even lose 3 credits: every request wrote "2," so the user ran 20 paid generations and still had 2 credits left. Second, a transaction alone did not help. PostgreSQL's transaction isolation docs explain why: Read Committed is the default, and a plain SELECT "sees a snapshot of the database as of the instant the query begins to run," so parallel transactions read the same balance. Third, the CHECK constraint stopped the balance from going below zero, but all 20 requests had already passed the check and done the paid work before 17 of them failed on the write. A constraint is a seatbelt, not the fix.

Fix 1: one statement that checks and deducts

If your server talks to Postgres directly (pg, Drizzle, Prisma raw SQL, or a server route using the Supabase secret key), put the check in the WHERE clause:

// Direct Postgres from server code (pg, Drizzle, Prisma raw SQL):
// one statement that checks and deducts. Returns null when the balance is too low.
export async function spendCreditsSql(db: Pool, userId: string, cost: number) {
  const { rows } = await db.query<{ credits: number }>(
    `update public.entitlements
        set credits = credits - $2
      where user_id = $1 and credits >= $2
      returning credits`,
    [userId, cost],
  );
  return rows[0]?.credits ?? null;
}

This works because of how Postgres handles two updates on the same row. The isolation docs say a second updater "will wait for the first updating transaction to commit or roll back," and then "the search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches." Once the balance hits 0, credits >= $2 is false and the row is not returned. Validate cost before you call it: it should be a positive server-side constant, never a value from the request. A negative cost would add credits. The OWASP cheat sheet lists this same pattern: "A conditional update (UPDATE ... WHERE balance >= amount) that succeeds only if the predicate still holds at write time."

Call it before the paid work, not after. If the model call then fails, give the credit back with a separate server-side update and log why.

Fix 2: a Supabase function with a row lock

With supabase-js you cannot write credits = credits - 1 from the query builder, which is why AI tools write the value read. Move the logic into a Postgres function and call it with rpc. First, take the balance away from the user:

-- 1. Users can read their balance but never write it directly.
drop policy if exists "Users can update own entitlements" on public.entitlements;
revoke insert, update, delete on table public.entitlements from anon, authenticated;

-- 2. A ledger, so every change has a reason.
create table public.credit_ledger (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users on delete cascade,
  delta int not null,
  reason text not null,
  created_at timestamptz not null default now()
);
alter table public.credit_ledger enable row level security;
revoke all on table public.credit_ledger from anon, authenticated;

Then the function. It locks the user's row with FOR UPDATE, checks, deducts, and writes a ledger row, all in the one transaction the function call runs in:

-- 3. Spend credits: lock the row, check, deduct, record. One transaction.
create or replace function public.spend_credits(p_cost int)
returns int
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_user uuid := auth.uid();
  v_balance int;
begin
  if v_user is null then
    raise exception 'Not signed in' using errcode = '28000';
  end if;
  if p_cost is null or p_cost < 1 or p_cost > 100 then
    raise exception 'Invalid cost' using errcode = '22023';
  end if;

  select credits into v_balance
  from public.entitlements
  where user_id = v_user
  for update;

  if v_balance is null or v_balance < p_cost then
    raise exception 'Not enough credits' using errcode = 'P0001';
  end if;

  update public.entitlements
  set credits = credits - p_cost
  where user_id = v_user
  returning credits into v_balance;

  insert into public.credit_ledger (user_id, delta, reason)
  values (v_user, -p_cost, 'spend');

  return v_balance;
end;
$$;

revoke execute on function public.spend_credits(int) from public, anon;
grant execute on function public.spend_credits(int) to authenticated;

PostgreSQL's locking docs say FOR UPDATE "causes the rows retrieved by the SELECT statement to be locked as though for update," so a second call waits until the first commits and then reads the new balance. The details that make it safe:

  • The user ID comes from auth.uid(), not a parameter, so a user cannot spend someone else's credits. That is the same rule as in the IDOR guide.
  • p_cost is limited to 1 through 100. In our test, p_cost: -50 was refused with "Invalid cost" and the balance did not change.
  • security definer is needed because authenticated can no longer update the table. Supabase's database functions docs say "Prefer security invoker" and that when you use definer "you must set the search_path," which set search_path = '' does. Every table name in the body is schema-qualified.
  • Execute is revoked from public and anon. PostgreSQL's CREATE FUNCTION docs note that "execute privilege is granted to PUBLIC for newly created functions." In our test, an anon call got "permission denied for function spend_credits."

The Security Advisor will show warning 0029 for these functions, because signed-in users can call a SECURITY DEFINER function. Here that is the point: the function only touches the caller's own row and checks its input. The Security Advisor guide explains when to accept that warning and when to revoke.

Call it from your server route with the signed-in user's Supabase client:

// Supabase: call the function with the signed-in user's client.
// The function reads auth.uid() itself, so the user ID is never a parameter.
export async function spendCredits(
  supabase: SupabaseClient,
  cost: number,
  runModel: () => Promise<string>,
) {
  const { data: balance, error } = await supabase.rpc('spend_credits', { p_cost: cost });

  if (error) {
    if (error.code === 'P0001') {
      return { status: 402, body: { error: 'Not enough credits' } };
    }
    throw error;
  }

  const output = await runModel();
  return { status: 200, body: { output, balance } };
}

A user could also call spend_credits straight from the browser, but all that does is spend their own credits. The paid work only happens in your route, after the deduction succeeded. Put a rate limit on that route as well (how to rate limit AI routes); a rate limit alone does not fix a race, because the requests it lets through can still arrive at the same moment.

Lock the balance column

None of this matters if users can still write their own balance. In our naive setup, which had the common "Users can update own entitlements" policy, the signed-in user's client set credits to 999999 in one call. After the revoke above, the same call returned 42501 permission denied for table entitlements, while reading the balance still worked. The column-level security guide covers the profile-table version of this, where plan, role, and credits sit next to fields users may edit.

One-time rewards, coupons, and free trials

A "claim your 50 free credits" button has the same race. Our naive version checked for an existing redemption row, inserted one, then added 50 credits. With 20 parallel claims, all 20 succeeded in every trial, the table ended with 20 redemption rows, and in 4 of 5 trials the user finished with 103 credits instead of 53.

The fix is to let the database refuse the second row. The constraints docs describe unique constraints, and INSERT ... ON CONFLICT DO NOTHING "simply avoids inserting a row" when one exists (INSERT docs). In PL/pgSQL, FOUND is false when the insert affected no rows (PL/pgSQL docs):

-- 4. One-time rewards: the database refuses a second row.
delete from public.reward_redemptions a
using public.reward_redemptions b
where a.user_id = b.user_id and a.reward = b.reward and a.id > b.id;

alter table public.reward_redemptions
  add constraint reward_redemptions_once unique (user_id, reward);
drop policy if exists "Users can add own redemptions" on public.reward_redemptions;
revoke insert, update, delete on table public.reward_redemptions from anon, authenticated;

create or replace function public.claim_welcome_bonus()
returns int
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_user uuid := auth.uid();
  v_balance int;
begin
  if v_user is null then
    raise exception 'Not signed in' using errcode = '28000';
  end if;

  insert into public.reward_redemptions (user_id, reward)
  values (v_user, 'welcome_bonus')
  on conflict (user_id, reward) do nothing;

  if not found then
    raise exception 'Already claimed' using errcode = 'P0001';
  end if;

  update public.entitlements
  set credits = credits + 50
  where user_id = v_user
  returning credits into v_balance;

  insert into public.credit_ledger (user_id, delta, reason)
  values (v_user, 50, 'welcome_bonus');

  return v_balance;
end;
$$;

revoke execute on function public.claim_welcome_bonus() from public, anon;
grant execute on function public.claim_welcome_bonus() to authenticated;
export async function claimWelcomeBonus(supabase: SupabaseClient) {
  const { data: balance, error } = await supabase.rpc('claim_welcome_bonus');

  if (error) {
    if (error.code === 'P0001') {
      return { status: 409, body: { error: 'Already claimed' } };
    }
    throw error;
  }

  return { status: 200, body: { balance } };
}

With this, 20 parallel claims gave exactly 1 success and 19 "Already claimed" in every trial, 1 redemption row, and 53 credits. The ledger matched: 33 spends totaling minus 33 and 5 bonuses totaling 250 across all fixed runs.

The same shape covers other one-time things:

  • Free trials: a unique constraint on the user (or account) and the trial type.
  • Invite or coupon codes with a use limit: update invites set uses = uses + 1 where code = $1 and uses < max_uses returning uses, then check you got a row back.
  • Referral rewards: a unique constraint on the referred user, so one signup pays one referrer once.
  • Stripe fulfillment: a unique constraint on the Checkout Session ID, as above.

OWASP API Security lists abuse of flows like these as API6:2023, unrestricted access to sensitive business flows, with a referral credit scheme as one of its examples. A unique constraint stops double claims by one account; it does not stop someone from making many accounts, which needs signup protections.

Mistakes AI tools make

  • Building price_data from req.body.amount or accepting priceId from the client "so the pricing page can pass the plan."
  • Applying a discount in the browser and sending the discounted total.
  • Granting credits from metadata.credits or the success page URL instead of the paid line items.
  • Read, check, call the model, then update({ credits: data.credits - 1 }).
  • Wrapping that in a transaction and assuming it is now safe.
  • Adding an "update own row" RLS policy so the browser can decrement credits.
  • Checking "already claimed" with a select and no unique constraint.
  • Passing user_id or cost into an RPC function from the client.
  • Writing a security definer function without set search_path and without revoking execute from anon.

How to test your own app

OWASP's advice is short: "If two requests can race, write a test that races them." Run it against a local or staging database with a test user, never production. Our test did this for each function above:

const results = await Promise.all(Array.from({ length: 20 }, () => spendCredits(supabase, 1, runModel)));

Then assert the end state: with 3 starting credits, exactly 3 results should be 200 and the balance should be 0. Do the same for every claim, redeem, and checkout route. The RLS testing guide shows how to run database tests like this on every pull request.

Checklist

  • Checkout routes accept a product ID, never an amount, currency, Price ID, or discount
  • Server-side catalog of Price IDs (or prices in your own database) with Object.hasOwn lookups
  • Quantity checked with Number.isInteger and a per-product maximum
  • Promotion codes applied with allow_promotion_codes or looked up on the server
  • Webhook grants credits from expanded line_items, once per Checkout Session ID
  • Idempotency keys on Stripe calls that create charges, refunds, or sessions
  • Credits spent with one conditional UPDATE or a FOR UPDATE function, before the paid work
  • No insert or update privilege on balances for anon and authenticated
  • Unique constraints on every one-time reward, coupon, trial, and fulfillment
  • security definer functions set search_path = '', use auth.uid(), validate inputs, and revoke execute from anon
  • A test that sends 20 parallel requests to each money or credits route

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Lovable, Bolt, or v0. Share file names and code only, never keys, tokens, or .env contents.

Audit this project for business logic flaws around payments and credits. 1) Find every place that creates a Stripe Checkout Session, PaymentIntent, subscription, or invoice. Flag any amount, unit_amount, currency, price or priceId, quantity, discount, coupon, or credit count that comes from the request body, query string, or client state. Replace it with a server-side catalog that maps a product ID to a Stripe Price ID, validate quantity as an integer between 1 and a per-product maximum, look up promotion codes on the server with stripe.promotionCodes.list({ code, active: true }) or use allow_promotion_codes, and add an idempotency key to Stripe calls that create charges, refunds, or sessions. 2) Check that fulfillment happens in the verified Stripe webhook, uses the expanded line_items of the Checkout Session, and records the session ID with a unique constraint. 3) Find every place that reads a balance, credit count, coupon, trial, or reward and then writes it in a separate query. Replace it with a single UPDATE ... WHERE credits >= cost RETURNING, or a Postgres function that uses SELECT ... FOR UPDATE, reads the user from auth.uid(), validates its inputs, sets search_path = '', and revokes execute from public and anon. Add unique constraints for one-time redemptions. 4) Make sure anon and authenticated cannot insert or update balance tables directly. 5) Write a test that sends 20 parallel requests to each of these routes and checks the final balance. Show me the diff and the SQL migration before applying anything.

Review the diff before you accept it. The security review prompt guide has more prompts like this, and protecting your OpenAI route covers the rest of a paid AI endpoint.

Sources

Questions

Can a user change the price in Stripe Checkout?

Not on Stripe's page, but they can change what your server asks Stripe for. If your route builds the Checkout Session from an amount, currency, quantity, discount, or Price ID in the request body, anyone can edit that request and Stripe charges whatever your server sends. In our test, a naive route passed unit_amount 1 straight to Stripe. Send only a product ID from the browser and look up the Stripe Price ID on the server.

How do I stop users from spending the same credits twice?

Do the check and the deduction in one database operation. Either a single UPDATE ... SET credits = credits - cost WHERE user_id = ... AND credits >= cost RETURNING credits, or a Postgres function that locks the row with SELECT ... FOR UPDATE before it checks. In our test, 20 parallel requests against 3 credits all succeeded with the usual read-then-write code. With either fix, exactly 3 succeeded in every trial and the balance ended at 0.

Is a database transaction enough to prevent a race condition?

Not by itself. PostgreSQL's default isolation level, Read Committed, lets two transactions read the same balance at the same time. We wrapped the read and the write in BEGIN and COMMIT, and 20 parallel requests still spent 3 credits 14 to 20 times. Add FOR UPDATE to the read, use a single conditional UPDATE, or use SERIALIZABLE with retries.

Do Stripe idempotency keys prevent double spending?

Only for Stripe API calls. A key makes Stripe return the saved result when the same request is sent again, so a double click or a network retry does not create a second charge or refund. It does nothing for your own database: credits, coupons, and one-time rewards still need an atomic update or a unique constraint, and your webhook still has to handle the same Checkout Session arriving more than once.

Should I validate promotion codes myself or use allow_promotion_codes?

The simplest safe option is allow_promotion_codes: true, which lets customers type the code on Stripe's own checkout page. If your page has its own code field, send the code to your server, look it up with stripe.promotionCodes.list({ code, active: true }), and pass its ID in discounts. Never accept a discount percent or a discounted total from the browser.