Skip to main content

What we find in apps built fast


Apps built quickly, whether by a small team or with an AI builder such as Bolt, Lovable or Cursor, tend to fail in the same handful of places once real users, data and money arrive. Most of those failures can be found in a short, focused review and fixed at the root, which is why the usual answer is a stabilization pass, not a rebuild.

This post walks through the failure classes we keep finding, the mechanism behind each, and a check you can run yourself. None of them is exotic. They come from the same source: a working demo proves the happy path, and nothing in the demo exercises the unhappy ones.

Authorization lives in the UI, not the backend

The most common finding, and the most serious. The interface hides the admin button from non-admins, the withdrawal screen only shows your own accounts, and everyone concludes the app is secure. But the backend function behind that button accepts any request that reaches it.

In one assessment, now a public case study, about 1 of ~80 backend functions verified the caller’s identity, and the database rules resolved to allow read and write for anyone. Withdrawal, bank-linking and payment endpoints were effectively open. The front end was fine; it simply was not the thing enforcing anything.

Check it yourself: take a request your browser sends for a privileged action, replay it with no session or with a low-privilege user’s token, and see whether the server refuses. Every function that touches data or money should establish who is calling before it does anything else.

Row-level security is missing or over-broad

On Supabase and other Postgres-backed stacks, the database is often reachable directly from the client with a public key. That is a sound design only if row-level security (RLS) does the isolation. We usually find one of three things: tables with RLS never enabled, policies written as using (true) to make an error go away, or policies that check that a user is signed in but not which organization the row belongs to.

Two queries find most of it:

-- tables in the public schema with RLS turned off
select tablename from pg_tables
where schemaname = 'public' and not rowsecurity;

-- policies that allow every row
select tablename, policyname, cmd from pg_policies
where qual = 'true' or with_check = 'true';

Then test isolation the way an attacker would. Create two test tenants, sign in as a user in tenant A, and using that user’s token against the same API the app uses, query tenant B’s rows by ID. The correct answer is zero rows, for reads, updates and deletes. Repeat for storage buckets, which have their own policies and are easy to forget.

Payments without idempotency keys

A payment call that times out has an unknown outcome. If the client or a background job retries it, and the request carries no idempotency key, the processor treats it as a new payment. In the same case study, no transfer carried an idempotency key, so a network retry could send a duplicate ACH transfer.

The fix is mechanical: generate one key per business intent (this payout, for this invoice), store it before calling the processor, send it on every attempt, and de-duplicate incoming webhooks by event ID. Stripe and Dwolla, for example, both accept an idempotency key on create calls for exactly this reason.

Lost edits from optimistic updates and autosave races

“Users say their changes don’t save” is a frequent complaint, and it is rarely one bug. The usual mechanisms:

  • Optimistic updates with silent failures. The UI shows the new value immediately and never checks the write. With RLS in place, an update the policy does not allow often returns success with zero rows changed rather than an error, so nothing tells the user.
  • Autosave races. Two saves are in flight, the older one lands last, and it overwrites the newer data. Sending the whole record on every save makes this worse, because a stale copy of an unrelated field rides along.
  • Uploads that report success early. The file reaches the browser’s upload step, but the record linking it to the profile is never written.

The fixes are to confirm the row count on every write, send only changed fields, serialize or version saves (an updated_at or version check on update), and surface failures to the user.

Security features that are facades

Some features exist in the interface and nowhere else. The clearest example we have seen: a multi-factor confirm endpoint that always returned “confirmed”, and a send-code endpoint that sent nothing. In the same system, a “money-out” feature wrote a bookkeeping record and returned a hard-coded balance without ever moving money.

These survive because they demo perfectly. The test is to try to fail them: enter a wrong MFA code, request a payout and check the processor’s dashboard, reset a password and confirm the old one stops working.

Secrets in the repository

Payment, email and bank-linking keys committed to git are common, and deleting the file does not remove them from history. Anyone who has ever cloned the repository has them. The order of operations matters: rotate the key at the provider first, then purge history, then move secrets into a vault or the platform’s secret store and inject them at runtime. Running a scanner such as gitleaks or trufflehog across full history takes minutes.

No visibility into real usage

Many apps reach beta with no way to answer “is anyone using this, and where do they get stuck?”. Error logs go to the browser console, there is no record of which flows fail, and the team learns about problems from users. A small amount of instrumentation, such as server-side error tracking, a handful of named product events, and a log of failed writes, turns beta from anecdote into evidence.

Lock-in to the builder

The last class is ownership. The app runs, but only inside the tool that built it: the schema was changed by hand in a console, environment variables live only in the builder’s settings, and there is no documented way to deploy it anywhere else. That is a business risk, not just a technical one. The remedy is schema migrations in the repository, a written list of every environment variable and external service, and a deployment path someone other than the original builder can run.

Why this rarely means a rebuild

Every class above has a targeted fix. Authorization moves to one enforced boundary. RLS policies are rewritten and tested. Payment calls gain keys. Saves gain version checks. The product logic, the UI and most of the code stay. A rebuild is warranted when the data model itself is wrong, and even then a staged migration usually beats a big-bang rewrite. Measured, not assumed: find out which situation you are in before deciding.

It also has to come before ongoing support. Proactive upkeep, meaning dependency and platform updates, security-policy spot checks, error and uptime review and backup verification, only works on a codebase that is stable enough for that time to go to upkeep rather than firefighting.

How we can help

Our Stabilization Assessment is a fixed-fee senior review of data access and row-level security, broken flows, lost data, visibility, and deployment and hand-off. You get a findings report and a prioritized fix list, and the plan is yours to act on with us, another team, or on your own.

Back to blog

Not sure where to start? Start with an assessment.

A senior review of your app, cloud estate, or AI platform, scoped and quoted before work starts, that ends in a prioritized plan, so you decide what to fix and when.

Talk to an engineer