VibeRescue · Blog

Supabase auth broken in a Bolt or Lovable app: redirect URLs and RLS

October 15, 2026 · 8 min read

Supabase is the default backend for apps built with Bolt and Lovable, and auth is where most of those apps break on their way to production. It typically shows up in one of three ways:

The first two are redirect and session problems. The third is Row Level Security (RLS). They need different fixes, so work through them in this order and stop at the first one that fails.

Layer 1: is the client talking to the right project?

Open the live site, press F12, go to Network, and try to log in. You should see a request to https://<ref>.supabase.co/auth/v1/token or /auth/v1/authorize.

Layer 2: Site URL and Redirect URLs

Supabase will only send users back to URLs it trusts. In the dashboard, go to Authentication → URL Configuration:

https://myapp.com/**
https://www.myapp.com/**
https://*-myteam.vercel.app/**     # Vercel preview deployments
http://localhost:5173/**           # local Vite dev

Next, check the code. Bolt and Lovable projects often hard-code the redirect:

// before: works only on the dev machine
await supabase.auth.signUp({ email, password,
  options: { emailRedirectTo: "http://localhost:5173/welcome" } });

// after: works everywhere
await supabase.auth.signUp({ email, password,
  options: { emailRedirectTo: `${window.location.origin}/welcome` } });

If redirectTo points to a URL that isn't on the allow-list, Supabase quietly falls back to the Site URL. That's why users "randomly" end up on localhost.

Google / GitHub OAuth

The provider has to send users back to Supabase, not to your app. In Google Cloud Console → Credentials → your OAuth client, the authorised redirect URI is https://<ref>.supabase.co/auth/v1/callback. Add your domain under authorised JavaScript origins. If the OAuth consent screen is still in "Testing" mode, only test users you've listed can sign in.

Layer 3: the session doesn't stick

If the login request returns 200 but you land back on /login, the session exists and your app isn't reading it in time. Two patterns cause most cases.

A protected route checks before the session loads (Vite/React). On first render, session is null while Supabase restores it from storage, so the guard redirects straight away. Track a loading state:

const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);

useEffect(() => {
  supabase.auth.getSession().then(({ data }) => {
    setSession(data.session); setReady(true);
  });
  const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
  return () => sub.subscription.unsubscribe();
}, []);

if (!ready) return <Spinner />;
if (!session) return <Navigate to="/login" replace />;

A Next.js app using the old helpers or missing middleware. Server Components can't see a session stored only in localStorage. Use @supabase/ssr with cookie-based clients, and add the middleware that refreshes the session on each request (Supabase's Next.js guide has the current template). Apps built with v0 or Cursor sometimes mix @supabase/auth-helpers-nextjs (deprecated) and @supabase/ssr. Pick one, preferably @supabase/ssr.

Layer 4: RLS: logged in, but no data

This is the one that confuses people most, because nothing looks broken. In Supabase, enabling RLS on a table with no policies means the API returns zero rows to the anon and authenticated roles. There's no error, just []. Inserts fail with new row violates row-level security policy.

First, look at what exists. In the SQL editor:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'public' order by tablename;

Then add one policy per operation. For a typical "users own their rows" table:

alter table public.todos enable row level security;

create policy "todos select own" on public.todos
  for select to authenticated using ((select auth.uid()) = user_id);
create policy "todos insert own" on public.todos
  for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "todos update own" on public.todos
  for update to authenticated using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
create policy "todos delete own" on public.todos
  for delete to authenticated using ((select auth.uid()) = user_id);

Two details trip up AI-generated schemas:

What not to do

Test it like an attacker would (5 minutes)

After fixing, sign in as user A and create a row. Sign in as user B in incognito. B must not see A's row. Then, logged out, try reading the table straight from the browser console:

const { data, error } = await supabase.from("todos").select("*");
console.log(data, error);   // logged out: expect [] (or an error), never real rows

Supabase's dashboard also has a Security Advisor (Database → Advisors). It flags tables with RLS disabled and policies that are too permissive. Run it before you announce your launch.

Quick reference

Email templates and scanners

If users report that magic links "already expired" the moment they open them, an email security scanner is probably consuming the one-time link first. Supabase documents this. Two mitigations:

Also check Authentication → Rate Limits. On the free tier, the built-in email sender is tightly limited (as of late 2024, a few emails per hour by default). During a launch, legitimate signups can silently fail until you attach a custom SMTP provider such as Resend or Postmark.

A staging checklist before you invite users

  1. Site URL is production; Redirect URLs cover production, previews and localhost.
  2. OAuth provider callback is the Supabase /auth/v1/callback URL.
  3. Every table that the browser touches has RLS on, with grants and policies for the operations it needs.
  4. A logged-out console query returns no private rows.
  5. User B cannot see User A's rows.
  6. Magic-link and OAuth flows both succeed in an incognito window on the live domain.
  7. Security Advisor shows no critical findings.

FAQ

Why does my Supabase login redirect to localhost:3000?

The Site URL in Supabase Authentication → URL Configuration is still localhost, or your redirectTo URL is not in the Redirect URLs allow-list, so Supabase falls back to the Site URL. Set the production URL and add your domains as Redirect URLs.

Why does my Supabase query return an empty array with no error?

Row Level Security is enabled on the table but there is no select policy for the role making the request. Add a policy such as using ((select auth.uid()) = user_id) for authenticated users.

What does "new row violates row-level security policy" mean?

The insert or update did not pass the policy's with check condition. Often user_id is not set on the new row. Send it from the client or default the column to auth.uid().

What does Supabase error 42501 permission denied for table mean?

The database role lacks a table grant, which is checked before RLS policies run. Grant the needed operations to the authenticated role, then make sure policies exist.

Is it safe to put the Supabase anon key in frontend code?

Yes, the anon (publishable) key is meant to be public, but only if RLS is enabled with correct policies. The service_role (secret) key must never be used in the browser.

Not sure which of these it is?

Run the free Rescue Scan: paste a public GitHub repo and VibeRescue, an automated AI repair tool, classifies the problem (code bug, missing files, or missing secrets) before you pay anything. If it is something you can fix with this guide, we will tell you. If you want a fix run, it opens a pull request, with a full refund if the run can't fix it.

Run the free Rescue Scan →