Supabase auth broken in a Bolt or Lovable app: redirect URLs and RLS
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:
- Sign-in sends you to
localhost:3000, or the email link says "Email link is invalid or has expired". - Login "works" but you're immediately bounced back to the login page.
- You're logged in, yet every query returns an empty list, or saving fails with
new row violates row-level security policyorpermission denied for table(error code42501).
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.
- No request at all, or one going to
undefined/auth/v1: yourVITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY(orNEXT_PUBLIC_...) aren't set on the host. Add them and redeploy. See our env var checklist. - The request goes to a different project ref than the one in your dashboard: Bolt and Lovable sometimes create a fresh Supabase project when you reconnect. Make sure the URL in your code matches the project you're configuring.
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:
- Site URL: your production origin, e.g.
https://myapp.com. This is the default target for confirmation and reset emails, and if you never changed it, it still sayshttp://localhost:3000. - Redirect URLs: every origin that receives callbacks. Glob patterns are allowed:
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:
- The insert doesn't set
user_id. The policy checksauth.uid() = user_id, but the frontend inserts{ title }only, souser_idis null and the check fails. Either send it from the client or give the column a default:alter table todos alter column user_id set default auth.uid(); permission denied for table(42501). This fires before any policy runs, and it means the role lacks a table grant. It's common for tables created with raw SQL migrations. Grant the operations the app needs:grant select, insert, update, delete on public.todos to authenticated;
What not to do
- Don't disable RLS to "make it work." The anon key ships in your JavaScript, so anyone can read it. With RLS off, anyone can read and change every row with a single curl command. Security researchers have repeatedly found exposed data in AI-built apps for exactly this reason.
- Don't use the
service_rolekey in the browser to bypass RLS. It skips every policy, and anyone who opens DevTools can see it. - Don't write
using (true)on tables with private data. It's the same as having no RLS for reads.
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
- Lands on localhost → Site URL / Redirect URLs / hard-coded
redirectTo. - "Email link invalid or expired" → link opened twice (email scanners do this), or the redirect isn't allowed.
- Bounced back to login → guard runs before the session loads, or SSR cookies are missing.
- Empty lists, no error → RLS enabled with no select policy.
violates row-level security policy→ missing insert/update policy, oruser_idnot set.42501 permission denied→ missing table grant.
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:
- In Authentication → Email Templates, change the confirmation link so it points at a page on your domain that shows a "Confirm" button. The button then navigates to the real Supabase link. Scanners that only fetch the first URL leave the real token untouched.
- If you use custom SMTP, turn off the provider's link tracking. Tracking rewrites the URL and breaks the one-time token.
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
- Site URL is production; Redirect URLs cover production, previews and localhost.
- OAuth provider callback is the Supabase
/auth/v1/callbackURL. - Every table that the browser touches has RLS on, with grants and policies for the operations it needs.
- A logged-out console query returns no private rows.
- User B cannot see User A's rows.
- Magic-link and OAuth flows both succeed in an incognito window on the live domain.
- 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.