DEV Community

Ruwithma Peiris
Ruwithma Peiris

Posted on

6 bugs I found auditing a Lovable + Supabase + Stripe app (and the fixes)

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;
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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" })
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Check yours in the Supabase SQL editor:

select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 ci failed 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

  1. Search for DEV_MODE, mock users and bypass.
  2. Run the pg_policies query above; every table the browser touches needs policies.
  3. Try to update your own is_pro / tier column from the browser console while logged in. It should fail.
  4. Open Stripe → Developers → Webhooks and look for failed deliveries.
  5. Grep supabase/config.toml for verify_jwt = false and 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)