AI-coded apps

    Why is my Lovable app hitting Supabase RLS error 403?

    Browser network panel showing a 403 Forbidden response from a Supabase REST insert made by a Lovable-generated frontend, with the new row violates row-level security policy message and Postgres error code 42501 visible in the response body

    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?

    SymptomLikely causeFix
    403 on every insert, no policies attachedRLS enabled, no INSERT policy definedAdd a for insert policy with the correct TO clause
    403 on insert after .select().single()Missing SELECT policy on the tableAdd a SELECT policy or pass { returning: 'minimal' }
    403 only for guest usersINSERT policy missing to anonAdd a policy with to anon or include both roles
    403 on .upsert() with existing rowMissing UPDATE policyAdd a for update policy with both USING and WITH CHECK
    403 with auth.uid() = user_id failingClient did not set user_id to the JWT subjectSet it to (await supabase.auth.getUser()).data.user.id
    Works in dashboard, fails in deployed appDashboard uses service_roleTest 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 public role does not match anon or authenticated cleanly. 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 include user_id in the insert payload. Either set it on the client or add a database default like default 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 public is 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.
    • #lovable
    • #supabase
    • #rls
    • #403 forbidden
    • #row level security
    • #postgres 42501
    • #ai-coded apps

    Frequently asked questions

    What does Postgres error 42501 mean in a Lovable app?
    It means insufficient_privilege. The Supabase API mapped the request to a Postgres role (anon or authenticated), then the row-level security policy on the target table did not permit the operation for that role. The response is HTTP 403 with the body new row violates row-level security policy for table. The fix is to add or correct a policy that matches the role, the command (INSERT, SELECT, UPDATE, DELETE), and the predicate the row needs to satisfy.
    Why does Supabase return 403 when I chain .select() after .insert()?
    Chaining .select() asks PostgREST to read the inserted row back. That read goes through the SELECT policy on the same table. If no SELECT policy permits the row, Postgres raises code 42501 on the read step and the whole request returns 403, even though the INSERT itself was accepted. Add a SELECT policy that matches the row, or pass { returning: 'minimal' } to skip the implicit read.
    Can I disable RLS to fix the 403 in my Lovable app?
    Technically yes; in practice no. Disabling Row Level Security on a table makes every row readable and writable by anyone who has the anon public key, which Lovable embeds in the compiled JavaScript bundle and which any visitor can copy. Treat RLS as the only layer between the public web and the database for a Lovable app. The right fix is a policy that names the role and the predicate the row has to satisfy.
    Should I use auth.uid() or the JWT sub claim in my Lovable policy?
    Use auth.uid(). It is a Supabase-provided function that reads the sub claim from the JWT attached to the request and casts it to UUID. Comparing auth.uid() to a user_id column on the row is the most common predicate shape. Reading the raw JWT through auth.jwt() is possible but more brittle: any change in how Supabase issues tokens (custom claims, new auth providers) can break the comparison silently.
    Is a 403 from Supabase a sign my Lovable app was hacked?
    No. A 403 with Postgres code 42501 means a row-level security policy refused a legitimate request from your own frontend. It is the database protecting itself, not evidence of an attack. The pattern to worry about is the opposite: a 200 OK on a request that should have been refused, which usually means RLS is off or a policy uses (true) where a user-scoped check belongs. A pre-launch audit checks for the second pattern.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free