I took an open-source SaaS app built with Lovable (React + Vite + Supabase + Stripe) and ran it end-to-end against a real local Supabase stack: real sign-ups, real database calls and correctly signed Stripe test events. I wrote 8 basic checks a paying-customer app should pass.
The original code passed 4 of 8. None of the failures showed an error in the UI. Here are the six bugs, with the fixes, so you can check your own app in a few minutes.
1. Login was switched off by a leftover dev flag
The auth context shipped with this:
// DEV MODE: set to false to re-enable real Supabase auth
const DEV_MODE = true;
Every visitor was silently "logged in" as a fake dev user. The protected pages protected nothing, and no real account ever touched the database.
Fix: make the bypass impossible in a production build.
const DEV_MODE = import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === "true";
Check yours: search the codebase for DEV_MODE, mockUser, bypass and hard-coded user IDs.
2. Every Stripe webhook was rejected
In a Supabase Edge Function (Deno), the synchronous stripe.webhooks.constructEvent throws, because Deno's crypto is async. So every webhook failed signature verification, and paying customers were never upgraded. Stripe's dashboard showed failed deliveries; the app showed nothing.
Fix:
const cryptoProvider = Stripe.createSubtleCryptoProvider();
event = await stripe.webhooks.constructEventAsync(
body, sig, webhookSecret, undefined, cryptoProvider
);
3. Stripe fields that no longer exist
The code read subscription.current_period_end and invoice.subscription. In the current Stripe API version those moved: the period end lives on each subscription item, and the invoice's subscription is under invoice.parent. Yearly subscribers crashed the subscription check (Invalid time value) and renewals were ignored.
Fix:
const ends = sub.items.data
.map((i) => i.current_period_end)
.filter((n): n is number => typeof n === "number");
const subscriptionId = invoice.parent?.subscription_details?.subscription;
Tip: run deno check on your functions with the real Stripe types (import via the npm:stripe specifier). In this app it surfaced 8 type errors at once, including a refund bug: any refund, even partial, downgraded the user.
4. Any user could make themselves Pro from the browser console
The billing columns (subscription_tier, stripe_customer_id, …) sat on the user_profiles table with no row level security. Any logged-in user could run this in the console:
supabase.from("user_profiles").upsert({ id: me, subscription_tier: "pro" })
Fix: RLS on every table the browser touches, plus a trigger so only the server can change billing fields, on INSERT as well as UPDATE:
alter table public.user_profiles enable row level security;
create policy "profiles: update own" on public.user_profiles
for update to authenticated
using (id = auth.uid()) with check (id = auth.uid());
create or replace function public.protect_billing_columns() returns trigger
language plpgsql as $$
begin
if current_user in ('service_role', 'postgres', 'supabase_admin') then
return new;
end if;
if tg_op = 'INSERT' then
new.subscription_tier := 'free';
new.stripe_customer_id := null;
else
new.subscription_tier := old.subscription_tier;
new.stripe_customer_id := old.stripe_customer_id;
end if;
return new;
end $$;
create trigger protect_billing_columns
before insert or update on public.user_profiles
for each row execute function public.protect_billing_columns();
Check yours in the Supabase SQL editor:
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
Any table missing from that list has no policies at all.
5. A public email relay
An edge function that sends email had verify_jwt = false in supabase/config.toml and no auth check of its own. The app never even called it from the browser, but anyone on the internet could, and send branded email to any address through the app's mail provider.
Fix: require the service-role key for internal-only functions.
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
const auth = req.headers.get("Authorization") ?? "";
if (!serviceKey || auth !== `Bearer ${serviceKey}`) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
6. The webhook swallowed errors and returned 200
The handler caught its own errors and still answered 200 OK, so when a database write failed, Stripe never retried. Return a 500 when processing fails, and Stripe will retry for days.
Bonus: things that looked fine in the editor
- The dashboard and history pages showed hard-coded demo sessions to every user, and finished sessions were saved to
"mock-session-id". -
npm cifailed because the lockfile was out of sync, so builds on Vercel/Netlify/CI broke. - Deep links returned 404 on refresh (no SPA rewrite).
- The main bundle was 1,000 kB; lazy-loading routes took it to 557 kB.
Result
| Check | Original | Fixed |
|---|---|---|
| Sign-up creates real accounts | ✓ | ✓ |
| Profile created on sign-up | ✓ | ✓ |
| Self-upgrade to Pro blocked | ✗ | ✓ |
| Owner can save/read their data | ✓ | ✓ |
| Other users can't read/write it | ✗ | ✓ |
| Signed Stripe payment upgrades the buyer | ✗ (HTTP 400) | ✓ |
| Forged Stripe event rejected | ✓ | ✓ |
| Email endpoint refuses strangers | ✗ | ✓ (HTTP 401) |
| Total | 4/8 | 8/8 |
A 5-minute self-check for your own app
- Search for
DEV_MODE, mock users andbypass. - Run the
pg_policiesquery above; every table the browser touches needs policies. - Try to update your own
is_pro/tiercolumn from the browser console while logged in. It should fail. - Open Stripe → Developers → Webhooks and look for failed deliveries.
- Grep
supabase/config.tomlforverify_jwt = falseand make sure each of those functions checks auth itself.
None of this is specific to Lovable; it's what happens when an AI tool gets an app to "works in the demo" and nobody tests the paths a real customer (or a curious one) takes.
I fix and finish AI-built apps (Lovable, Bolt, Replit, Cursor) with a small studio in Colombo. If you'd rather have someone run these checks on your app: Fiverr.
Top comments (0)