You added an insert to a Supabase table from a Lovable app, the form submits, and the network panel returns 403 Forbidden with the body new row violates row-level security policy for table and a Postgres error code of 42501. The same SQL runs without issue in the Supabase SQL editor, the user is signed in, and the table has Row Level Security enabled with a policy already attached. The question is which check is rejecting the row, and which line of the policy needs to change so the next submit returns 201 Created.
Short answer
A Lovable app returns 403 Forbidden on Supabase when no Row Level Security policy on the target table permits the operation for the role the frontend carries. The role is anon for guests and authenticated for signed-in users. Chaining .select() after .insert() also requires a SELECT policy, and a missing one returns the same 403 with Postgres error code 42501.
What you should know
- RLS is enabled by default on new tables in Supabase. A table with no policy attached refuses every read and write from the anon and authenticated roles.
- The TO clause names the role. Without it, the policy defaults to the public role, which some Lovable setups do not map cleanly to anon or authenticated.
- INSERT policies use WITH CHECK, not USING. The check evaluates the row being inserted; if it returns false, Postgres raises code 42501.
- Chained
.select()requires a SELECT policy. Lovable's generated insert calls often end with.select().single(), which adds an implicit SELECT after the insert. - 403 carries code 42501; 401 means no JWT. A 401 is an authentication failure, while a 403 is a policy decision against an authenticated request.
- The service_role key bypasses RLS. It is also fully readable when pasted into Lovable frontend code, so the fix is never to embed it.
Why does Supabase return 403 instead of 401 on a Lovable app?
A 401 means the request did not present a valid JWT to PostgREST. The request reached the gateway without an Authorization header, the token was expired, or the signature did not validate. A 403 means PostgREST authenticated the request, mapped it to a Postgres role, and the role then ran into a Row Level Security policy on the target table that did not permit the operation.
For a Lovable app, the JWT typically comes from supabase.auth.getSession() and is attached automatically by @supabase/supabase-js. The role inside the JWT is authenticated when a user is signed in and anon when no session exists. According to Supabase's Row Level Security guide, policies should always specify the role in the TO clause; a policy without TO defaults to the public role and may not match either of the two roles a Lovable frontend actually carries.
The Postgres rejection arrives at the API as the JSON body { "code": "42501", "message": "new row violates row-level security policy for table ...", "details": null, "hint": null }. The 42501 code is documented by Postgres as insufficient_privilege. PostgREST translates that into HTTP 403, which is what shows up in the Lovable network panel.
What does the INSERT policy actually need to allow?
A minimal INSERT policy that lets a signed-in user insert their own row looks like this:
create policy "Users insert their own rows"
on public.posts
for insert
to authenticated
with check (auth.uid() = user_id);
Three parts have to align. The for insert keyword limits the policy to INSERT; other operations need their own policies. The to authenticated clause restricts the policy to requests carrying the authenticated role; an anon request will not match. The with check (auth.uid() = user_id) predicate evaluates against the row being inserted: if the client sets user_id to anything other than the current user's id, Postgres raises 42501 and PostgREST returns 403.
When the row is meant to be inserted by a guest visitor (for example, a public contact form), the role has to be anon and the predicate typically reflects that there is no user link:
create policy "Guests submit contact messages"
on public.contact_messages
for insert
to anon
with check (true);
A with check (true) is sometimes appropriate for fully public submission tables, but it is also the policy shape most often abused; pair it with rate limits and column-level validation. Supabase's documentation on INSERT policies notes the WITH CHECK expression is evaluated against the new row, so any column the client did not set arrives at its default value, including columns intended to be filled server-side.
Why does the same insert work in the SQL editor but fail from Lovable?
The Supabase SQL editor runs as the postgres superuser, which is exempt from Row Level Security entirely. The dashboard's table editor uses the service_role key, which is also exempt. Both of these bypass every policy, so a query that works in the dashboard tells you almost nothing about whether the policy is correctly written for the frontend role.
The Lovable preview and the deployed Lovable app run in the browser, where the request carries the anon public key as apikey and either no JWT (role anon) or a JWT issued to the signed-in user (role authenticated). The Lovable Supabase integration documentation notes that Lovable can scaffold basic RLS policies on request, but it explicitly recommends reviewing them in the Supabase dashboard under Auth, Policies before launch.
A useful debugging step is to log the session right before the insert: console.log(await supabase.auth.getSession()). If the session is null, the role is anon and the policy TO clause has to name anon. If the session has a user, the role is authenticated. A mismatch here is the single most common cause of the 403 on Lovable apps where everything else looks right.
Why does the INSERT fail even after I add the policy?
A pattern that catches many Lovable apps: the generated client code reads await supabase.from('posts').insert(row).select().single(). The trailing .select().single() means PostgREST issues an implicit SELECT to return the inserted row, and that read goes through the SELECT policy on the same table. If no SELECT policy permits the row, Postgres raises 42501 on the read step and the whole call returns 403, even though the INSERT itself succeeded.
This behaviour is the root cause of Supabase issue 35368, where developers found that an INSERT with with check (true) still returned 403 because no SELECT policy existed. Two fixes resolve it. Either add a SELECT policy that permits the new row:
create policy "Users read their own rows"
on public.posts
for select
to authenticated
using (auth.uid() = user_id);
Or change the client call to skip the implicit SELECT:
await supabase.from('posts').insert(row, { returning: 'minimal' });
The same pattern affects .upsert(). When the row already exists, Postgres runs the UPDATE policy; when it does not, it runs INSERT. Both policies have to be in place, and the SELECT policy is also needed when .select() is chained. The Supabase community has confirmed this behaviour: see Supabase discussion 36243, where a logical-delete UPDATE failed with 42501 even though the user owned the row.
How do common Lovable RLS 403 patterns compare?
| Symptom | Likely cause | Fix |
|---|---|---|
| 403 on every insert, no policies attached | RLS enabled, no INSERT policy defined | Add a for insert policy with the correct TO clause |
403 on insert after .select().single() | Missing SELECT policy on the table | Add a SELECT policy or pass { returning: 'minimal' } |
| 403 only for guest users | INSERT policy missing to anon | Add a policy with to anon or include both roles |
403 on .upsert() with existing row | Missing UPDATE policy | Add a for update policy with both USING and WITH CHECK |
403 with auth.uid() = user_id failing | Client did not set user_id to the JWT subject | Set it to (await supabase.auth.getUser()).data.user.id |
| Works in dashboard, fails in deployed app | Dashboard uses service_role | Test from the deployed frontend, not the SQL editor |
A two-policies-per-table baseline (INSERT and SELECT) catches most Lovable 403 cases. Add UPDATE and DELETE policies as the app starts to support edits and removals; do not leave them open as a shortcut.
What to watch out for
A handful of patterns turn a one-line fix into a long debugging session.
- Pasting the service_role key into Lovable to bypass policies. The key then lives in the JavaScript bundle, fully readable in the browser, granting any visitor unrestricted access to every table. Rotate it in the Supabase dashboard the moment this happens.
- Default TO clause set to public. Lovable sometimes scaffolds a policy without an explicit TO line. In some Supabase project versions the
publicrole does not matchanonorauthenticatedcleanly. Always name the role in the TO clause. - The client sets columns the policy expects the server to fill. A
with check (auth.uid() = user_id)predicate fails if the client did not includeuser_idin the insert payload. Either set it on the client or add a database default likedefault auth.uid(). - An old session keeps the request as authenticated after logout. Lovable sometimes caches the session; calling
supabase.auth.signOut()does not always clear it in the preview environment. Reproduce the bug in an incognito window. - Chaining
.select()for return values when none are needed. The implicit SELECT triggers a SELECT policy check that the team often forgot to add. Drop the chain or add the policy. - Schema changes after the policy was written. Renaming a column referenced in a WITH CHECK predicate breaks the policy silently; Postgres still evaluates it, just against a column that no longer exists, returning false.
For Lovable builders who want an automated read of the Supabase RLS surface (which tables have RLS on, which policies match anon versus authenticated, whether the service_role key leaks into the compiled bundle) before pointing a custom domain at the app, PTKD.com (https://ptkd.com) is one of the platforms that scans AI-coded frontends for these patterns and reports the missing or over-permissive policies, with findings aligned to OWASP MASVS expectations for backend access control.
Key takeaways
- A 403 from Lovable to Supabase almost always means RLS is enabled and no policy permits the operation for the role the request carries. The Postgres code is 42501.
- The TO clause must name the role the frontend actually uses: anon for guests, authenticated for signed-in users. A default of
publicis not enough. - Chaining
.select()after.insert()pulls a SELECT policy into the call. Add the SELECT policy or pass{ returning: 'minimal' }. - The service_role key bypasses RLS in the dashboard. It is also fully readable when pasted into the Lovable bundle, so the fix is never to embed it on the frontend.
- Some teams add an external pre-launch sweep of Supabase RLS coverage and key exposure with platforms like PTKD.com (https://ptkd.com), which surfaces the missing or over-permissive policies before a Lovable app reaches production traffic.




