Your upload fails. The error says new row violates row-level security policy. You read the policy, you test it in the SQL editor, it returns true. You swap the anon key for service_role to check you are not going mad, and the upload works instantly.
That sequence has a specific ending, and we see it in scans constantly: the service key stays in the code, ships to production, and now the whole project is readable by anyone who opens the JavaScript bundle. This page is about not getting there.
TL;DR
As of 2026-08-24, Supabase's troubleshooting page for this error says the Storage API runs INSERT ... RETURNING * and tells you to add a SELECT policy. That is true for upsert: true and not for a normal upload: the Storage source only attaches RETURNING * to the upsert path, and Supabase's own access control guide says INSERT alone is what an upload needs. Adding the SELECT policy anyway widens read access on storage.objects and usually leaves the real bug in place.
First, the status code is probably not 403
Almost every writeup of this error, including the title of Supabase's own troubleshooting entry, calls it a 403.
The status on the wire is usually 400. What you get back looks like this:
{
"statusCode": "403",
"error": "Unauthorized",
"message": "new row violates row-level security policy"
}
The 403 is a string inside the body. The HTTP response is a 400. If you are filtering your browser's network tab by status, filter on the message text instead, or you will scroll past the failing request looking for a red 403 that never appears.
Supabase's Storage service also normalises the Postgres error before returning it. Postgres raises 42501 with a message ending ... for table "objects"; Storage maps that to the shorter string above. So the wording you see from the API and the wording in your database logs are not identical, and searching for one will not find the other.
The fix in the docs, and where it comes from
Supabase's troubleshooting entry gives this cause:
The Supabase Storage API executes an
INSERToperation followed by aRETURNING *clause to provide object details back to the client.
And this consequence: if a SELECT policy is missing, "the database is unable to return the row metadata," so the whole transaction fails. The prescribed fix is a SELECT policy on storage.objects mirroring your INSERT conditions.
It is a plausible story. It is also the exact mechanism that bites people using supabase-js to insert into a normal table, where the client does append a select to return the full record. Table inserts go through PostgREST. Storage does not.
What the Storage source actually does
The Storage service builds two different statements, and only one of them returns anything.
-- createObject: a normal upload
INSERT INTO storage.objects (${insert.columns})
VALUES (${insert.placeholders})
-- no RETURNING clause
-- upsertObject: upload with upsert enabled
INSERT INTO storage.objects (${insert.columns})
VALUES (${insert.placeholders})
ON CONFLICT (name, bucket_id) DO UPDATE SET ${updateClause}
RETURNING *
A plain upload never asks the database to hand the row back, so there is nothing for a missing SELECT policy to block. This is not a recent change that the docs failed to catch up with, either: the older knex implementation had the same split, with .returning('*') only on the upsert method.
Supabase's access control guide says it plainly, and contradicts the troubleshooting page:
the only RLS policy required for uploading objects is to grant the
INSERTpermission to thestorage.objectstable. To allow overwriting files using theupsertfunctionality you will need to additionally grantSELECTandUPDATEpermissions.
The troubleshooting entry also carries an unfilled template placeholder in its resolution text, telling you to add a policy to example_schema.example_table. Read it as a low-review auto-generated page rather than maintained guidance, and prefer the access control guide when the two disagree.
So: if you pass { upsert: true }, the docs are right and you need SELECT and UPDATE as well. If you do not, adding a broad SELECT policy grants every matching user read access to object metadata they previously could not see, and your upload still fails.
What to check instead
Confirm whether you are upserting. Look for { upsert: true } in the .upload() call. If it is there, add SELECT and UPDATE policies matching your INSERT conditions and you are done.
Stop testing the policy in the SQL editor. This is the single most misleading step in the whole debugging loop. The editor runs without request.jwt.claims set, so any auth.uid() or JWT-claim expression in your policy evaluates in a context the real request never has. A policy that returns true there has told you nothing.
Check the path, not just the user. Storage policies almost always match on the object name, and storage.foldername(name) or split_part(name, '/', 1) against a user ID is where these break. Log the exact name your client sends and compare it character for character with what the policy expects. A leading slash, or userId where the policy wants auth.uid()::text, fails silently as a policy mismatch.
Reproduce with curl and a real user JWT, not the anon key alone. That is the only way to see the policy evaluated with the same claims your app sends.
# Grab a real session token from your app (browser devtools, Application, Local Storage)
TOKEN="eyJ..."
PROJECT="your-project-ref"
curl -i -X POST \
"https://$PROJECT.supabase.co/storage/v1/object/your-bucket/$(uuidgen).png" \
-H "Authorization: Bearer $TOKEN" \
-H "apikey: $YOUR_ANON_KEY" \
-H "Content-Type: image/png" \
--data-binary @test.png
Check for triggers on the storage schema. A failing trigger can surface as a policy-shaped error that has nothing to do with your policies. One reported case of this exact message turned out to be a broken trigger, with the user already on service_role and RLS not involved at all.
Why this one is worth writing about
Three GitHub discussions carry this shape, and none has an accepted answer: #46022 (2026-05-16), #48349 (2026-07-27), and #37611 (2025-08-01).
Worth being precise about what those threads do and do not show. They are not unanswered: #46022 drew two substantive diagnostic replies, and the best reply in #48349 is the request.jwt.claims point above. Nobody in any of them recommends using service_role as the fix. One responder raises it specifically to draw the distinction, that service_role bypasses RLS and anon does not.
What the threads do show is the temptation. The title of #48349 is a developer reporting that the upload "works with service_role" and that the anon key does not. That is a person with a deadline, an error message whose official explanation does not apply to them, and one key sitting right there that makes the problem go away.
What reaching for the service key actually costs
service_role (a secret key in Supabase's newer key format) uses Postgres's BYPASSRLS attribute. That is a role attribute, not a per-table setting, so it does not just open the bucket. Supabase documents it as having "full access to your project's data, bypassing Row Level Security," and service keys as "entirely bypassing RLS policies, granting you unrestricted access to all Storage APIs."
Put that key in a React or Vue app and it is compiled into a JavaScript file your visitors download. Every table, every row, read and write, for anyone who opens devtools. This is the single most damaging finding we produce in scans of AI-built apps, and it does not usually arrive through carelessness. It arrives through exactly the sequence at the top of this page.
The anon key is genuinely safe in a browser. Supabase describes the publishable key as "safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code," because RLS is what guards it. The secret key has no such property, and no policy you write applies to it.
If you already shipped one, treat it as burned: rotate it in the dashboard first, then fetch your deployed bundles and grep for JWT-shaped strings to confirm nothing else went out with it. Rotation before investigation, every time.
Sources
| Claim | Source | Checked |
|---|---|---|
Troubleshooting page blames INSERT ... RETURNING * and prescribes a SELECT policy | Supabase troubleshooting a94384 | 2026-08-24 |
createObject issues no RETURNING; only upsertObject does | supabase/storage src/storage/database/pg.ts | 2026-08-24 |
| INSERT alone is required to upload; SELECT and UPDATE only for upsert | Supabase Storage access control | 2026-08-24 |
supabase-js table inserts do append a select, which is where the mechanism is real | supabase-js insert reference | 2026-08-24 |
service_role uses BYPASSRLS; publishable/anon key is safe to expose | Supabase API keys | 2026-08-24 |
| Unresolved reports of this error | #46022, #48349, #37611 | 2026-08-24 |
Why does my Supabase Storage upload fail with the anon key but work with service_role?
Because service_role carries Postgres's BYPASSRLS attribute, so it skips every policy you wrote rather than satisfying them. It working is not evidence your policy is correct. It is evidence your policy is being evaluated at all, and failing, which narrows the bug to the policy or to the auth context the request arrives with.
Is the Supabase Storage RLS upload error a 403 or a 400?
The HTTP status on the wire is usually 400. The JSON body contains a statusCode field with the string 403 in it, which is where the confusion comes from. If you are filtering your network tab for 403 you will miss the request entirely, so filter on the message text instead.
Do I need a SELECT policy on storage.objects to upload a file?
Not for a plain upload. Supabase's access control guide states that the only RLS policy required for uploading is INSERT on storage.objects, and that SELECT and UPDATE are needed additionally only for upsert. Their troubleshooting page prescribes a SELECT policy for the error generally, which is broader than the access control guide and broader than the Storage source code supports.
Why does my policy pass in the SQL editor but fail from the app?
The SQL editor runs without request.jwt.claims set, so anything in your policy that reads auth.uid() or a JWT claim evaluates differently there than it does on a real request. A policy that returns true in the editor has told you almost nothing about what happens when the Storage API runs it.
What happens if I ship the service_role key to fix this?
You expose the entire project, not just the bucket. BYPASSRLS is a Postgres role attribute rather than a per-table setting, and Supabase documents service keys as entirely bypassing RLS policies with unrestricted access to all Storage APIs. Anyone who opens your JavaScript bundle can then read and write every table you have.
Is a service key in your bundle right now?
We fetch your deployed JavaScript, decode every JWT in it, and tell you which ones bypass your policies.