
The context
I built a Lovable app in 20 minutes to see what's possible. With no technical instructions. Here's a component from the code. It's the Stripe checkout for that app. The user clicks "Upgrade", a panel opens on the right, the Stripe form is embedded inside, you pay, it's smooth. Demo perfect.
So the code was generated by an AI agent. 50 lines. It works.
It also has 5 problems, including 2 serious ones. And it's exactly the kind of code I see every week.
Let's break it down together.
The code in question
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js';
import { getStripe, getStripeEnvironment } from '@/lib/stripe';
import { supabase } from '@/integrations/supabase/client';
import { useCallback } from 'react';
export function CheckoutSheet({
open,
onOpenChange,
quantity,
customerEmail,
userId,
companyId,
onComplete,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
quantity: number;
customerEmail?: string;
userId?: string;
companyId?: string;
onComplete?: () => void;
}) {
const fetchClientSecret = useCallback(async () => {
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
priceId: 'pro_monthly_per_seat',
quantity,
customerEmail,
userId,
companyId,
environment: getStripeEnvironment(),
returnUrl: `${window.location.origin}/admin/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
},
});
if (error || !data?.clientSecret)
throw new Error(error?.message || 'Failed to start checkout');
onComplete?.();
return data.clientSecret as string;
}, [quantity, customerEmail, userId, companyId, onComplete]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>Upgrade to Pro</SheetTitle>
</SheetHeader>
<div className="mt-4">
{open && (
<EmbeddedCheckoutProvider
stripe={getStripe()}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</div>
</SheetContent>
</Sheet>
);
}
At first glance, it looks clean. Properly typed, hooks used correctly, Stripe embedded inside a shadcn Sheet. A junior dev would merge this without blinking. So would an AI agent.
First red flag though: the Supabase call is made directly inside the component. No dedicated hook, no service, no API client. The UI talks to the backend in a straight line. It seems harmless, but it isn't: with no intermediate layer, there's no natural place to draw the line between "what the client sends" and "what the server must revalidate". Everything is mixed, and there's no architecture.
Now let's look at what's actually happening.
Problem 1: userId and companyId come from the client (serious)
Look at these lines:
body: {
...
userId,
companyId,
...
}
userId and companyId are passed as props to the component, so they come from the client. And the client sends them back to the server in the request body.
Why is that a problem? Because anything that goes through the client can be modified. A user opens the browser DevTools, intercepts the request, changes userId to someone else's, and clicks "Pay". Stripe charges. The server attaches the subscription to the other user.
Concretely: I can subscribe your account to the Pro plan with my card. You didn't ask for it, you didn't pay, but you become "Pro". Worse, I can do the opposite: if the logic relies on companyId, I can attach my payment to someone else's company and benefit from their limits.
What you should do instead: the server NEVER trusts these values. It reads userId itself from the Supabase auth session (server-side), and derives companyId from the database, verifying that the authenticated user actually belongs to that company.
Simple rule: identity is read on the server, never on the client.
Problem 2: hardcoded priceId on the client (serious)
priceId: "pro_monthly_per_seat",
The client tells the server what price it wants to pay. Again, anyone can intercept this request and send a different priceId. For example, the priceId of the free plan or a 1€ trial plan.
If the server trusts that value and creates the Stripe session with it, the user pays 1€ and gets the 99€/month Pro plan.
What you should do instead: the client sends an abstract identifier ("pro_monthly", "team_yearly"). The server has a hardcoded mapping (server-side) between this identifier and the actual Stripe priceId. This way, even if the client tampers with the call, it can't make up an arbitrary priceId.
Better yet: the server also validates that the plan is compatible with the user (for example, no "team" plan for a solo user).
Problem 3: onComplete is called before payment (very serious)
This is the sneakiest bug in the file. Look:
const fetchClientSecret = useCallback(async () => {
const { data, error } = await supabase.functions.invoke("create-checkout", { ... });
if (error || !data?.clientSecret) throw new Error(...);
onComplete?.(); // <-- here
return data.clientSecret as string;
}, [...]);
fetchClientSecret is the function that starts the Stripe session. Not the one that confirms it's done. It's called when the payment form is shown, not when the user actually entered their card.
So onComplete is called before the user pays anything. If onComplete does something like "mark this user as Pro" or "send the welcome email", you do it for anyone who opens the Sheet, even if they never entered a card.
Attack scenario: I open the payment Sheet, I see the Stripe form, I close it without paying. My account just became Pro for 30 days. For free.
What you should do instead: the only source of truth for "payment succeeded" is a Stripe webhook (checkout.session.completed), received server-side. The frontend can show a "Payment in progress" toast at most. All business logic (changing the plan, sending the email, opening access) fires inside the webhook handler, never before.
Problem 4: success comes from a URL parameter
returnUrl: `${window.location.origin}/admin/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
When the payment is done, Stripe redirects the user to this URL. You might be tempted, on the /admin/billing page, to do:
if (searchParams.get('checkout') === 'success') {
// mark as Pro / show confetti / etc.
}
Except ?checkout=success is just a string in the URL. Anyone can visit /admin/billing?checkout=success&session_id=whatever directly, without going through Stripe. And trigger the "success" logic.
It's the same problem as before: you trust the client for something that has to be validated on the server.
What you should do instead: on the return page, if you want to confirm the payment, take the session_id and call your server with it. The server then asks Stripe (via API) for the actual status of that session. And answers "yes or no". The client doesn't guess the status, it requests it.
Problem 5: environment sent from the client
environment: getStripeEnvironment(),
The client tells the server "are you in test or live mode?". It's the opposite of what should happen. The server knows its own environment (via an env variable, e.g. NODE_ENV or a dedicated flag). It doesn't need to receive it from the client.
The risk: if the server trusts this parameter, you could potentially flip between test and live env by modifying the request. Depending on how it's implemented server-side, that goes from harmless (test payments rejected) to broken (mixed keys, crossed Stripe accounts).
What you should do instead: remove the field. The server knows where it lives.
Bonus: what doesn't break security, but still needs cleanup
- No visible error handling. If
fetchClientSecretthrows,EmbeddedCheckoutstays blank. The user sees an empty Sheet and has no idea why. You'd want at least an error toast and a fallback state. - No loading state. Between the click on "Upgrade" and the Stripe form showing up, there's a network call. During that time, the Sheet is open but empty. A spinner would do.
- The title is hardcoded in English ("Upgrade to Pro") in a presumably bilingual app. Hardcoded, not i18n. Detail, but it's the same laziness throughout the file.
- The interfaces are inline in the component signature. Prop types are declared on the fly. For something as central as "what you need to start a checkout", this deserves a dedicated file (a
checkout.types.tsfor example), so it's reusable, testable, and readable without having to parse a sprawling signature. - Stripe is hardwired into the component.
getStripe,EmbeddedCheckoutProvider,EmbeddedCheckout: everything is referenced directly. If tomorrow you want to switch to Lemon Squeezy, Paddle, or add a SEPA direct flow for enterprise clients, you have to refactor half the file. An abstraction layer (ausePaymentSessionhook, or an agnostic<PaymentProvider />component) would let you swap providers without touching the UI. This can sometimes be overkill, but it's worth weighing depending on the scale of the app you're building.
The rule that sums it up
If you only remember one thing from this article:
Anything that touches money is never validated from the client. Always with a webhook.
The client can be useful to start an action (open a form, show a UI). But the moment you change important state on the server (a plan, a balance, an access right), the decision is made server-side, based on signed events (Stripe webhook) or data the server reads itself (auth session, database).
Anything that comes from a user request body is suspect by default.
This rule doesn't come from a tutorial. You learn it once you've hit the problem. And the AI has never hit the problem. It generates code that works, not code that resists.
What an audit would do on this file
Here's what an audit on this kind of code looks like, concretely:
- Security read. I trace every field sent to the server and ask "what happens if an attacker modifies it?". Here, I found 4 problematic fields in 30 seconds.
- Lifecycle read. I look at when each callback fires. The premature
onCompleteis typical. An AI dev doesn't think in lifecycle, it thinks in "function that seems logical to call here". - Server read. I go look at the
create-checkoutfunction (the Supabase function) to see if it compensates for the client's problems. Often partially yes, often not on the critical point. - Prioritized fix plan. I give you 3 levels. What must be patched today (the money-out vulnerabilities), what can wait a week (UX), what's cosmetic.
For this specific component, the plan fits on one page. And the server-side changes would take a senior dev half a day.
To be clear: AI is not the enemy
I'm not saying you should stop generating code with an AI. The component above is fairly well structured. It works technically. The problem isn't the AI, it's shipping it to production without review.
The AI produces a first draft that works. It doesn't produce code that will hold up in production for the next 10 years.
If you're vibe coding your SaaS and you touch payments, auth, or user data, that's exactly the moment to bring in a human who can check everything.
Want me to look at your code?
If you have a payment, auth, or critical-flow component generated by an AI, and you want to know what's hiding inside before a user finds out before you, check out the Audit offer. I read the code, list the real risks, and give you a prioritized fix plan.
And if you want more line-by-line audits in this style, join the newsletter. No spam, just the traps worth avoiding.

