12 min read
Implemented in Checkout Kit
Full-stack Stripe checkout starter: product catalog, cart, orders, and admin — React + Go or Node + MongoDB
See it running — Checkout Kit demoThe generated app runs. It signs users in, lists records, takes a payment, and the screens look better than most things you have shipped by hand. Then it meets production: two people using it at once, a webhook delivered twice, a user who opens the console, a clock that moves an hour.
None of what follows is an argument against generating code. Models write the boring 90% faster and more consistently than most of us do by hand. The problem is the remaining 10%, and that 10% is not spread evenly across the codebase. It sits in four places, and it is the same four places nearly every time.
This is the one that turns a demo into an incident. The generated app hides the delete button when user.role !== 'admin', and everyone reads that as "only admins can delete".
It is not. The button is a rendering decision. The endpoint is still open, and reaching it takes one line:
curl -X DELETE https://app.example.com/api/orders/42 -H "Authorization: Bearer <any valid token>"If the server does not check, the record is gone. A hidden button is not access control, it is a suggestion.
The fix is not to remove the UI check. You need both, but they do different jobs: the UI decides what to show, the server decides what to allow. The trap is doing them from two different sources of truth, so they drift apart the first time a rule changes.
One policy, one engine, both sides:
const engine = createRbacEngine(policy);
// The server: this is the security boundary.
router.post('/orders/:id/refund',
requireAuth(secret),
rbacGate(engine, 'order', 'refund'), // 403 if the policy says no
controllers.orders.refund,
);// The UI: this is an affordance, and nothing more.
<Can action="refund" resource="order"><RefundButton /></Can>You can watch the difference instead of taking my word for it. The Checkout Kit demo has three roles. Sign in as Sales and you get the same screens as the admin, with every input locked and every mutating action gone. Delete the UI checks and the app is still safe, because the 403 is what actually stops the request. That is the test: if removing the frontend makes your app insecure, you never had authorization.
The full loop, including how ownership conditions stay in sync, is in RBAC in React and Node.
Everything in your frontend bundle is public. Not "obscured", not "minified", public: it is a text file served to anyone who asks. Generated code puts API keys there constantly, because in a demo it works.
The distinction that matters, using payments as the example:
pk_...) is designed to be public. It belongs in the browser.sk_...) must never leave your server. With it, anyone can read your customers and move your money.The same rule extends past keys, to anything the client should not be trusted to decide. The amount charged is the clearest case. If the browser sends the price, the browser sets the price:
// Wrong: the client tells the server what to charge.
POST /checkout { productId: 'kit', amountCents: 1 }
// Right: the client names the product, the server prices it.
POST /checkout { productId: 'kit' }Identifiers go up, the amount is read server-side from your own catalog. A tampered request then changes nothing, because the number was never in the request.
A demo is tested by one person, once, going forward in time, in one timezone. Production is none of those things.
The failure is rarely a crash. It is a wrong answer delivered confidently, which is far more expensive because nothing alerts you. My favourite example, because it looks trivial and is not: a working day is 09:00 to 17:00, and generated code stores it as a UTC instant. That is correct until the clock changes, and then every slot is silently off by an hour for half the year. It looks fine. It is wrong.
There is a whole guide on that one, with the failing case and the test that catches it: availability slots across timezones and DST.
The general shape is the same everywhere. Two users buy the last item at the same moment. A form is submitted twice because the first response was slow. A list grows past one page. None of these appear when you click through your own app.
The most common payment bug in generated code is treating the redirect as the confirmation. The user is sent back to /success, so the order is marked paid.
The redirect is a browser navigation. It can be closed, refreshed, bookmarked, or never happen at all. The webhook is the payment provider telling your server what actually occurred, and it is the only thing that should move your order state.
Webhooks are also delivered more than once by design. So the handler has to be idempotent: the same event arriving twice must not charge twice, fulfil twice, or send two emails. That means verifying the signature over the raw body, recording the event id before acting, and refusing to reprocess one you have already handled.
Refunds and disputes arrive the same way, long after the customer has gone. If your app only handles payment_intent.succeeded, the first charge.refunded leaves your database claiming money you no longer have.
The four failures above have nothing to do with how the code was written. Hand-written code has shipped every one of them, repeatedly, for twenty years. What changed is the ratio: generating the 90% is now nearly free, so the 10% is a much larger share of what is left, and it arrives all at once, at the end, when you thought you were done.
The industry has noticed. MindStudio's analysis of why AI-generated apps fail in production names the same clusters: frontend-only auth, fake backends, exposed keys, happy-path testing. It is a vendor's argument for their own tool rather than measured research, but the pattern it describes is real and it matches what these four sections show.
A starter kit does not remove the need to understand any of this. If you cannot tell why the 403 matters, you will remove it the first time it gets in your way.
What a kit gives you is the boring 10%, already fought and covered by tests, so your effort goes to the part that is actually yours: the domain. And a kit can be wrong too. The difference from a generated demo is that you can check it, because it runs against a real backend and a real payment provider rather than a mock.
Before you call a generated app production-ready:
sk_, secret, password. Anything you find is public.The Checkout Kit demo runs all four of these against a real backend, a real database and a real Stripe sandbox: the rbacGate boundary with three roles, an SCA-ready payment flow, signed webhook handling for both payment_intent.succeeded and charge.refunded, and 314 tests. It is the same code that ships, so you can poke at it before deciding whether any of this is worth buying rather than building.