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

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
- RLS on storage.objects is on by default. Even a public bucket refuses uploads until an INSERT policy on storage.objects permits the operation.
- The public flag governs SELECT only. Public buckets let anyone read a file by URL. Insert, update, copy, and delete still run through RLS.
- The role is set by the client. Lovable's generated frontend uses the anon public key, so the request carries the anon role unless the user is signed in through supabase.auth.
- The 403 body names the exact violation. The string new row violates row-level security policy means the INSERT check failed, not that the bucket is missing.
- The owner_id column links the row to the JWT subject. Folder-scoped policies often compare auth.uid() against the first path segment or against owner_id.
- Service role bypasses RLS, never expose it in Lovable. The service_role key carries unrestricted access and belongs in server functions, not in frontend code.
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 pattern | Role at request time | INSERT policy TO clause | Predicate |
|---|---|---|---|
| Authenticated user uploads an avatar | authenticated | to authenticated | bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text |
| Guest visitor uploads to a public bucket | anon | to anon | bucket_id = 'public-uploads' |
| Signed-in user attaches a file to a record they own | authenticated | to authenticated | bucket_id = 'attachments' and owner_id = auth.uid() |
| Admin uploads from a server function (Edge Function) | service_role | no policy needed; service_role bypasses RLS | n/a |
| Mixed: signed-in or guest | authenticated, anon | two policies, one per role | bucket_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.
- The bucket id in the policy does not match the bucket id in the upload call. Bucket names are case-sensitive. A policy on bucket_id = 'Avatars' does not match an upload to from('avatars').
- The TO clause is omitted, so the policy defaults to public. Some Supabase project versions evaluate the public role separately from anon and authenticated, and the request fails despite the policy looking correct in the dashboard.
- Uploading with upsert: true triggers an UPDATE check. When a file at the same path already exists, the storage gateway runs the UPDATE policy, not INSERT. Both have to be present.
- The Lovable preview signs in as a different user than production. The preview environment sometimes carries a session that the deployed site does not. The same code returns different 403 outcomes between environments.
- The service_role key was pasted into the frontend to make uploads work. The key now sits in every page bundle and grants any visitor full read and write across the database. Rotate the key in the Supabase dashboard the moment this is spotted.
- The 403 response body looks like an authentication problem. It is not. The role is authenticated correctly; the row is being rejected by the policy. Calling supabase.auth.refreshSession() does not change the outcome.
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
- Lovable's 403 on Supabase storage is almost always a missing or mis-scoped INSERT policy on storage.objects, not a bucket flag and not an authentication bug.
- The public flag governs reads. Uploads run through RLS whether the bucket is public or not.
- Match the TO clause to the role the Lovable frontend actually carries. Use supabase.auth.getSession() to confirm whether the request is anon or authenticated at upload time.
- Never paste the service_role key into the Lovable code to bypass policies. The key is fully readable in the browser bundle and grants any visitor unrestricted access to the database.
- Some teams outsource the pre-launch sweep of Supabase storage policies, RLS gaps, and key exposure to platforms like PTKD.com (https://ptkd.com), which reports the missing or over-permissive policies before the Lovable app reaches production traffic.
- #lovable
- #supabase
- #storage
- #rls
- #403 forbidden
- #bucket policy
- #upload
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
Journal · AI-coded appsWhy is my Lovable app hitting Supabase RLS error 403?Lovable apps return 403 with Postgres code 42501 when the Supabase RLS policy for the INSERT command does not match the role the frontend actually carries.8 min read
Journal · AI-coded appsWhy does my Lovable.dev app return 403 on Supabase storage upload?A Lovable.dev file upload that fails with 403 Forbidden almost always points at a missing storage RLS policy, not a bug. Here is the fix.9 min read
Journal · AI-coded appsWhat should I check in my Lovable app before App Store submission?A pre-submission security pass for Lovable builds: Supabase RLS, exposed keys, Edge Functions, and the App Review and Play policies that catch them.8 min read
Journal · AI-coded appsBolt.new vs Lovable: which is safer to ship to production?A side by side look at how Bolt.new and Lovable handle secrets, Supabase RLS, and server side logic, and what each builder gets wrong by default.8 min read
Scan your app in minutes
Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.
Try PTKD free