AI-coded apps

    Why does my Lovable.dev app return 403 on Supabase storage upload?

    Lovable.dev preview pane showing a profile photo upload returning a 403 Forbidden error from the Supabase storage API, with the storage.objects RLS policy editor open alongside it

    A Lovable.dev builder finishes the profile photo screen, clicks upload, and the network tab lights up with a 403 Forbidden from a URL ending in /storage/v1/object/avatars/.... The Lovable preview shows a generic error toast. The Supabase dashboard shows nothing at all. The bucket exists, the file is below the size limit, and the user is signed in. The question is why the request still fails.

    Short answer

    A Lovable.dev app returns 403 Forbidden on a Supabase storage upload because the request does not match any policy on the storage.objects table. Supabase enables Row Level Security on every new bucket by default, which the Supabase storage access control guide describes in those exact terms: "Storage does not allow any uploads to buckets without RLS policies." The anon role and the authenticated role both need an explicit INSERT policy on storage.objects before any write succeeds. The fix is to confirm the user is authenticated on the client, add the INSERT policy that targets the right bucket_id, and add SELECT plus UPDATE policies when the call uses upsert: true.

    What you should know

    • Supabase enables RLS on storage.objects by default. A new bucket has no policies, so every upload from the anon or authenticated role returns 403 until at least one INSERT policy is added.
    • The Lovable.dev client uses the anon key, not the service_role key. The anon key is signed for either the anon role or the authenticated role depending on the user session, and both need policies to write.
    • The INSERT policy must match the bucket_id. A policy that names the wrong bucket, or that uses a path prefix the client does not actually send, denies every upload silently.
    • Upsert needs more than INSERT. The Supabase access control guide notes that upsert: true triggers a SELECT followed by an UPDATE, so the role also needs those two permissions on the bucket.
    • A 400 Bad Request can carry a 403 body. Supabase issue 35157 documents storage requests where the HTTP status is 400 while the response body holds the row-level security violation text.

    What does the 403 Forbidden response from Supabase storage actually mean?

    The 403 status comes from the PostgREST layer that fronts the storage.objects table. When a supabase.storage.from(bucket).upload(path, file) call reaches Supabase, the storage API converts it into an INSERT into storage.objects with the file metadata. Because RLS is on, Postgres runs the row through every INSERT policy attached to that table for the current role. If no policy returns true, the insert is rejected with the standard Postgres error new row violates row-level security policy, which the storage API surfaces as HTTP 403.

    The 403 is a policy decision, not an authentication failure. A signed-out user with no JWT runs as the anon role. A signed-in user runs as the authenticated role. Both roles can hit the same 403 if no policy permits their insert. The error message in the JSON body is the most reliable signal, since it names the table and the operation that was blocked.

    A second pattern worth knowing: a trigger on storage.objects that fails inside a function will also surface as a row-level security message, even though the cause sits in the trigger. The Supabase storage discussion 37611 walks through one such case where a failing storage trigger looked exactly like an RLS denial.

    Why does Lovable.dev produce this error so often on first launch?

    Lovable.dev generates a working upload component on the first prompt. The component calls supabase.storage.from('avatars').upload(...), which is the documented client pattern. The component does not generate the matching storage policy, because policies live in the Supabase project, not in the Lovable codebase. When the builder runs the preview, three things have to line up for the upload to succeed: the bucket exists, the user is authenticated as expected by the client code, and the storage policies allow the role to write.

    In practice the builder hits the 403 in one of four configurations:

    1. The bucket was created, but no policies were added. The Supabase Dashboard table editor shows the bucket as private with zero rules. Every upload returns 403.
    2. The bucket has a policy for the anon role, but the client now runs under the authenticated role. The policy does not match, so the request fails.
    3. The Lovable.dev client uploads to a path that the policy does not allow. A policy that requires (storage.foldername(name))[1] = auth.uid()::text denies an upload where the path is public/file.jpg.
    4. The policy is correct, but Lovable.dev calls upload(..., { upsert: true }). The upsert triggers a SELECT and an UPDATE, both of which need their own policies.

    The Lovable documentation calls out the general principle in its Supabase integration guide: default Supabase rules are permissive for development, and RLS policies must be set up before production. The guide does not include the storage-specific policy templates, which is where the 403 then comes from in real projects.

    What is the minimal Supabase storage policy that makes the 403 go away?

    The minimum is one INSERT policy on storage.objects that targets the correct bucket and role. For a private avatars bucket where any signed-in user can upload, the policy is:

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

    That policy alone is enough to stop the 403 for a signed-in user calling upload. It is also wide open inside the bucket, so any authenticated user can write any path. For a real app, scope the policy to the user's own folder:

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

    With that policy, the Lovable.dev client must pass paths like ${user.id}/profile.jpg. The Supabase access control guide describes this pattern as the standard approach for per-user folders.

    If the upload uses upsert: true, add the matching SELECT and UPDATE policies:

    create policy "users can read their own avatar"
    on storage.objects
    for select
    to authenticated
    using (
      bucket_id = 'avatars'
      and (storage.foldername(name))[1] = auth.uid()::text
    );
    
    create policy "users can update their own avatar"
    on storage.objects
    for update
    to authenticated
    using (
      bucket_id = 'avatars'
      and (storage.foldername(name))[1] = auth.uid()::text
    );
    

    Without the SELECT policy, an upsert that finds an existing file fails before it ever reaches the UPDATE step, which the user sees as another 403.

    How do the policy requirements compare across the common upload patterns?

    The table below summarises which policies each pattern needs. The pattern names match what Lovable.dev typically generates on a first prompt.

    Upload patternBucket typeRequired policies on storage.objectsCommon 403 cause
    Anonymous upload to a public bucketPublicINSERT for anon, scoped to bucket_idNo INSERT policy on bucket
    Signed-in user upload, shared folderPrivateINSERT for authenticated, scoped to bucket_idPolicy targets anon, not authenticated
    Signed-in user upload, per-user folderPrivateINSERT for authenticated, scoped to bucket_id and foldernameClient uploads to wrong path shape
    Signed-in user upsert, per-user folderPrivateINSERT, SELECT, UPDATE for authenticated, all scopedMissing SELECT or UPDATE policy
    Server-side upload from an Edge FunctionPrivateNone on storage.objects, service_role key bypasses RLSservice_role key accidentally on client
    Presigned upload URLPrivateINSERT policy that the URL creator satisfiesTrigger on storage.objects raising an exception

    The last row is worth a note. The Supabase storage-js issue 186 tracked a case where a presigned upload URL produced a row-level security error even though the URL was minted with the service_role key. The fix involves making sure the role that creates the URL also satisfies the INSERT policy, not only the role that uses the URL.

    How do I debug a 403 inside a Lovable.dev project step by step?

    Four checks, in this order, cover almost every 403 on a Supabase storage upload from Lovable.dev.

    1. Confirm the user is authenticated as the client expects. Open the Lovable.dev preview, sign in, then in the browser console run await window.supabase.auth.getUser(). The response should contain a non-null user object with an id and an aud of authenticated. A null user explains a 403 on any policy that targets the authenticated role.
    2. Read the JSON body, not just the status. In the network tab, open the failing upload and look at the response body. A body containing new row violates row-level security policy confirms the cause. A different message points elsewhere, for example a missing bucket or a wrong content-type.
    3. List the policies on storage.objects. In the Supabase Dashboard, open Storage, then the bucket, then Policies. Confirm there is at least one INSERT policy that targets the role the client uses and the bucket_id the client writes to. The Dashboard shows each policy as a single row with its operation, role, and SQL.
    4. Replay the upload path. Copy the file path the client sends, then check it against the policy. A policy that requires (storage.foldername(name))[1] = auth.uid()::text denies any upload whose first folder is not the user id. The Lovable.dev component must pass the path in that exact shape.

    For builders who want an external automated read of their AI-coded project before it reaches real users, PTKD.com (https://ptkd.com) is one of the platforms that scans deployed apps and connected Supabase projects for missing storage policies, exposed keys, and the most common AI-coded vibe-coding mistakes. The report names the bucket and the policy gap, which makes the loop between a 403 in the network tab and the policy that needs to exist shorter than a manual audit.

    What should I never do to make the 403 go away?

    A few shortcuts look attractive at 2am and turn into a worse problem the next morning.

    • Pasting the service_role key into the Lovable.dev client. The Supabase API keys documentation is explicit that the service_role key bypasses every policy and must stay server-side. A key prefixed with VITE_ ships in the bundle and lets any visitor read or rewrite the entire database, not only the storage bucket.
    • Disabling RLS on storage.objects. The setting can be flipped, but it removes the policy layer from every bucket in the project at once. A single misnamed file path then becomes a full bucket read for any caller.
    • Making every bucket public to dodge the read policy. Public mode only affects read. A public bucket without an INSERT policy still returns 403 on upload, and the public URL of every file becomes guessable.
    • Allowing a policy where bucket_id IS NULL. A policy without a bucket scope applies to every bucket in the project, which often grants more than the developer intended.
    • Trusting the Lovable.dev chat to write the policy. Lovable can generate the SQL, but the policy must be applied in the Supabase Dashboard or migration and then re-tested with a real signed-in user, not the preview's default session.

    Key takeaways

    • The 403 Forbidden on a Supabase storage upload in a Lovable.dev app is a missing or mismatched RLS policy on storage.objects, not a Supabase outage and not a bug in Lovable.
    • The minimum fix is one INSERT policy on storage.objects that targets the right role and the right bucket_id. Per-user folders need the storage.foldername helper. Upsert needs SELECT and UPDATE too.
    • Read the response body, not the HTTP status. A 400 can carry a 403 message, and a row-level security message can hide a failing trigger.
    • The service_role key never belongs in a Lovable.dev client bundle. Any privileged upload runs through a Supabase Edge Function that holds the key as a secret.
    • Some teams outsource the pre-release scan of their AI-coded apps to platforms like PTKD.com (https://ptkd.com), which flags missing storage policies, exposed keys, and other vibe-coding security gaps before users encounter them.
    • #lovable.dev
    • #supabase
    • #supabase storage
    • #rls
    • #403 forbidden
    • #ai security
    • #vibe coding
    • #file upload

    Frequently asked questions

    Why does the 403 error appear only after I add Supabase Auth to my Lovable.dev app?
    Before Auth is wired in, Lovable.dev often calls Supabase as the anon role against a public bucket with a loose policy. Once Auth is in place, the request runs under the authenticated role, and the storage.objects policies you wrote for anon no longer apply. The 403 is a sign that the new role has no INSERT policy on the bucket. Add a policy that targets the authenticated role and the same bucket_id.
    Should I use the service_role key in my Lovable.dev client to bypass the 403 error?
    No. The Supabase API keys documentation states that the service_role key bypasses every RLS policy and must run on a server you control. A Lovable.dev project bundles client code into a public JavaScript file, so the key would ship to every visitor. The right path is to keep the anon key on the client, fix the storage policy, and move any privileged upload through a Supabase Edge Function that holds the service_role key as a secret.
    How do I let each user upload only to their own folder in a Lovable.dev storage bucket?
    Use the storage.foldername helper inside the policy. Structure file paths as auth.uid()/filename.jpg, then write an INSERT policy on storage.objects with WITH CHECK that compares storage.foldername(name)[1] to the JWT subject. The Supabase access control guide shows the exact pattern. The Lovable.dev client must pass the path in that shape, otherwise the policy denies the upload and the request returns 403.
    Why does my upload sometimes return 400 Bad Request with a 403 body from Supabase storage?
    That mismatch was reported as a Supabase storage bug on GitHub issue 35157. The HTTP status is 400, but the JSON body contains the row-level security violation message a 403 carries. Treat the body, not the status, as the source of truth. The fix is the same as a clean 403, which is to add or correct the INSERT policy on storage.objects so the request matches the rule.
    Will making the Supabase storage bucket public remove the 403 error for Lovable.dev uploads?
    Making the bucket public removes the 403 only on read, not on write. The Supabase storage access control guide states that uploads, updates, and deletes still need RLS policies regardless of whether the bucket is public. A public bucket without an INSERT policy keeps returning 403 on upload. Public mode also lets every visitor read every file by URL, which is rarely the right default for user content.

    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