AI-coded apps

    Why does my Lovable app return 403 Forbidden on Supabase storage?

    Browser network panel showing a 403 Forbidden response from a Supabase storage upload made by a Lovable-generated frontend, with the row-level security violation message visible in the response body
    You added an upload component in Lovable, the user clicks the button, and the network panel returns 403 Forbidden from your Supabase project at /storage/v1/object//. The body of the response reads new row violates row-level security policy. The bucket exists, the file is small, and the Lovable preview ran fine yesterday. The question is which check on the storage.objects table is refusing the insert, and what to change so the next upload returns 200 OK.

    Short answer

    Lovable apps hit 403 Forbidden on Supabase storage when no Row Level Security policy on the storage.objects table grants the INSERT (or UPDATE) operation to the role making the request. The Supabase Storage API enforces RLS on uploads even when the bucket is public; the public flag only relaxes downloads (SELECT on the asset URL), not writes. The fix is to add a CREATE POLICY on storage.objects with a TO clause that names the role your Lovable client carries (authenticated for signed-in users, anon for guest uploads), scoped to the bucket_id you write to. Supabase's Storage Access Control documentation lists the policy shapes that pass.

    What you should know

    Why does Supabase return 403 Forbidden when the bucket is public?

    Public on a Supabase bucket affects one thing: file retrieval by URL. According to Supabase's Storage bucket fundamentals page, a public bucket lets anyone read a file once they have the URL, but access control is still enforced for uploading, deleting, moving, and copying. Every write goes through Row Level Security on the storage.objects table.

    The Storage REST endpoint at /storage/v1/object// calls into Postgres with the role and JWT carried in the Authorization header. Postgres evaluates the matching CREATE POLICY rule for the INSERT command. If no policy applies, the row is rejected, the storage gateway translates the rejection into HTTP 403, and the body returns the JSON envelope { statusCode: "403", error: "Unauthorized", message: "new row violates row-level security policy" }. The same response surfaces in supabase/storage issue 640, where contributors confirmed that a failed RLS check on storage.objects returns 403 with the row-violation message regardless of the bucket's public flag.

    For a Lovable app, the request originates in the generated React frontend, which uses the anon public key from the SUPABASE_ANON_KEY constant. The role the request carries is anon when no user is signed in, and authenticated when a session is present. The policy on storage.objects has to name that role in its TO clause, or the insert fails.

    What does the INSERT policy on storage.objects actually need?

    A policy that lets a signed-in user upload to a bucket called avatars looks like this. Add it through the Supabase dashboard at Storage > Policies, or paste the SQL into the SQL editor.

    create policy "Authenticated users upload to avatars"
    on storage.objects
    for insert
    to authenticated
    with check (bucket_id = 'avatars');
    

    Three parts have to align. The for insert keyword limits the policy to the INSERT operation; reading and deleting still need their own policies. The to authenticated clause restricts the policy to requests that arrive with the authenticated role; a request from the anon role does not match and the insert continues to fail. The with check (bucket_id = 'avatars') predicate confirms the row being inserted targets the right bucket.

    When the request comes from a logged-out visitor and you want the upload to succeed (for example, a public contact form attaching a screenshot), the role has to be anon:

    create policy "Anon users upload to public-uploads"
    on storage.objects
    for insert
    to anon
    with check (bucket_id = 'public-uploads');
    

    For a per-user folder structure where each user only writes to their own subfolder, use auth.uid() against storage.foldername(name):

    create policy "Users upload to their own folder"
    on storage.objects
    for insert
    to authenticated
    with check (
      bucket_id = 'user-files'
      and (storage.foldername(name))[1] = auth.uid()::text
    );
    

    The path passed to supabase.storage.from('user-files').upload(...) then has to start with the auth.uid() of the signed-in user. Uploading to a path that begins with anything else fails the with check predicate and the request returns 403. The general predicate language and how Postgres evaluates it is covered in Supabase's Row Level Security guide.

    Why does the same upload work in the Supabase dashboard but fail from Lovable?

    The dashboard's Storage browser runs as the service_role key. Service role bypasses RLS entirely, so the same file uploaded through the web UI lands in the bucket without touching any policy. The Lovable frontend cannot do the same: it runs in the visitor's browser, where the service_role key would be fully readable in JavaScript, network requests, and source maps.

    This mismatch is the most common source of the bug. A developer drags an image into the dashboard, sees it appear in the bucket, and assumes the bucket is configured. The next attempt from the deployed Lovable app returns 403 because the anon or authenticated role has no INSERT policy. The fix is not to copy the service_role key into the Lovable code (which exposes the key publicly and breaks every RLS policy in the project), but to add a policy that names the role the frontend carries.

    A useful test is to call supabase.auth.getSession() in the Lovable component right before the upload and log the result. If the session is null, the role is anon. If the session has a user, the role is authenticated. The TO clause in the policy has to match the role observed at that point. The Lovable Supabase integration documentation notes that Lovable can scaffold basic policies on request, but they always need to be reviewed in the Supabase dashboard before launch.

    How do policy shapes compare for common Lovable upload patterns?

    Upload patternRole at request timeINSERT policy TO clausePredicate
    Authenticated user uploads an avatarauthenticatedto authenticatedbucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text
    Guest visitor uploads to a public bucketanonto anonbucket_id = 'public-uploads'
    Signed-in user attaches a file to a record they ownauthenticatedto authenticatedbucket_id = 'attachments' and owner_id = auth.uid()
    Admin uploads from a server function (Edge Function)service_roleno policy needed; service_role bypasses RLSn/a
    Mixed: signed-in or guestauthenticated, anontwo policies, one per rolebucket_id = 'shared'

    The two-policies-per-role pattern catches a quiet failure. A policy created with a default TO public clause does not always match anon or authenticated in every Supabase project version, so naming the role in the TO clause is the safer shape. Each operation (SELECT, INSERT, UPDATE, DELETE) carries its own policy, and uploading also writes to owner_id and updates metadata, so an UPDATE policy is sometimes required as well, especially when the storage-js client uses upsert: true.

    What to watch out for

    A few patterns turn a one-policy fix into hours of debugging.

    For builders who want an external automated read across the Supabase RLS surface (storage policies, table policies, exposed service-role keys in the compiled Lovable bundle) before pointing a custom domain at the app, PTKD.com (https://ptkd.com) is one of the platforms that scans Lovable-generated apps for these patterns and flags missing or over-permissive policies aligned with OWASP MASVS expectations for backend access control.

    Key takeaways

    Frequently asked questions

    Do I need an RLS policy for a public Supabase bucket in my Lovable app?
    Yes for any upload, delete, copy, or move. The public flag on a Supabase bucket only relaxes file retrieval by URL; every write to storage.objects still passes through Row Level Security. Without an INSERT policy on storage.objects that matches the role the Lovable frontend carries, the request returns 403 Forbidden with new row violates row-level security policy in the body, even though the bucket is marked public.
    Why does the dashboard let me upload to the bucket but the deployed Lovable app does not?
    The Supabase dashboard uses the service_role key behind the scenes, which bypasses every RLS policy on storage.objects. Your deployed Lovable app uses the anon public key from the frontend, so the role on the request is anon or authenticated. Adding an INSERT policy with TO authenticated (or TO anon) and the matching bucket_id is what closes the gap between the two environments.
    Can I paste the service_role key into Lovable to make uploads work?
    No. The service_role key is a secret that grants unrestricted access across every table and bucket in the Supabase project. Pasting it into Lovable's frontend code embeds it in the JavaScript bundle, where any visitor reading the page source or network requests can copy it and read or modify the entire database. Rotate the key in the Supabase dashboard the moment it appears outside server code.
    How is 403 on storage different from the 401 I sometimes see?
    A 401 Unauthorized means the request did not carry a valid JWT; supabase-js could not authenticate the user at all. A 403 Forbidden means the JWT authenticated, the role was recognised, but the RLS policy on storage.objects did not permit the operation. The 401 is fixed by signing the user in. The 403 is fixed by adding or correcting the policy that names the role the request carries.
    Should the policy use auth.uid() or owner_id when scoping uploads to a user?
    Either works, with a trade-off. Using (storage.foldername(name))[1] = auth.uid()::text keeps one folder per user and is enforced at upload time, but the path passed to the upload call has to start with the user id. Using owner_id = auth.uid() relies on the storage gateway setting owner_id to the JWT subject automatically, which it does for authenticated requests. Folder-based scoping tends to be more visible during audits.

    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