Storage upload fails with anon key (RLS) despite correct policies/grants — works with service_role #48349
Replies: 5 comments
|
Does it work for the publishable key? |
|
Check storage logs for error also. |
|
Storage RLS and table RLS are separate. A policy that works for Checklist for anon uploads1. Bucket is not 2. Policies on create policy "allow uploads to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);For 3. 4. Send the user access token const { data: { session } } = await supabase.auth.getSession();
// upload uses session.access_token automatically when logged inWithout a logged-in user, you're 5. Inspect policies select * from pg_policies where tablename = 'objects' and schemaname = 'storage';6. Read the Storage error body Common fixAdd an INSERT policy on Tooling: RLS Inspector helps audit table policies; for Storage, verify If you paste your bucket name + existing storage policies (redact user ids), I can suggest the minimal policy diff. |
|
Hi,
Thanks for looking into this. Here's a summary of everything I've already
verified on my end, so you can rule these out quickly:
*Issue:* Uploading a file to Supabase Storage via the anon key fails with:
403 Unauthorized — "new row violates row-level security policy"
*Project ref:* fakklaqisfofkpagxotk
*Bucket:* product-photos (also reproduced on a freshly created bucket
product-photos-v2 with identical policies, to rule out bucket-specific
corruption)
*What I've already confirmed is correct:*
- RLS policies on storage.objects — INSERT and UPDATE policies exist,
are PERMISSIVE, and have with_check: true (unconditional)
- SELECT policy on storage.buckets is also PERMISSIVE with qual: true
- Base GRANTs confirmed via information_schema.role_table_grants — anon
has insert/select/update on storage.objects
- authenticator role has confirmed membership into anon (checked via
pg_auth_members)
- No custom triggers on storage.objects fire on INSERT (only DELETE and
UPDATE triggers exist)
- Manually running the same insert as set role anon directly in the SQL
Editor succeeds
- Using the service_role key for the exact same upload succeeds
immediately
- Bucket config confirmed clean: public: true, no file size limit, no
MIME restriction
- Anon key in my app matches the key currently shown in Settings → API
exactly
- Request headers (apikey and authorization) both carry the correct anon
JWT
*One thing I noticed:* in the Storage logs, requests are routed through
storage-api-canary-lb-ap-south-1-adm.supabase.co. Is this project currently
on a canary/beta Storage API build? Given that everything checks out at the
database/policy/grant level, I'm wondering if this could be a bug specific
to that build rather than something in my configuration.
Happy to provide any additional logs, request IDs, or timestamps if that
helps you investigate further.
Thanks,
Priyansh
…On Wed, Jul 29, 2026 at 11:37 AM MicroDegree Career Assistance < ***@***.***> wrote:
Storage RLS and *table RLS are separate*. A policy that works for
public.your_table does not automatically apply to storage.objects.
Checklist for anon uploads
*1. Bucket is not public unless you intend it*
Private buckets require *storage.objects* policies.
*2. Policies on storage.objects (not your app table)*
Typical authenticated/anon upload pattern:
create policy "allow uploads to own folder"on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
For anon, you usually *should not* allow arbitrary uploads — if you must,
scope tightly (specific bucket + path prefix) and rate-limit at the edge.
*3. service_role bypasses RLS — anon does not*
If uploads work with service_role but fail with anon key, the bug is
almost always *storage policy* or *missing JWT* (client not sending user
session).
*4. Send the user access token*
With @supabase/supabase-js, ensure the client has a session before upload:
const { data: { session } } = await supabase.auth.getSession();// upload uses session.access_token automatically when logged in
Without a logged-in user, you're anon.
*5. Inspect policies*
select * from pg_policies where tablename = 'objects' and schemaname = 'storage';
*6. Read the Storage error body*
Supabase Storage often returns 403 with a Postgres error payload — share
error / message from the SDK response.
Common fix
Add an INSERT policy on *storage.objects* for the bucket, with WITH CHECK
validating bucket_id and path ownership via auth.uid().
------------------------------
Tooling: RLS Inspector <https://github.com/habinrahman/rls-inspector>
helps audit table policies; for Storage, verify storage.objects
explicitly in SQL.
If you paste your bucket name + existing storage policies (redact user
ids), I can suggest the minimal policy diff.
—
Reply to this email directly, view it on GitHub
<#48349?email_source=notifications&email_token=CCS7GKYWBAUEA5UBS7EBRA35HGIDXA5CNFSNUABIM5UWIORPF5TWS5BNNB2WEL2ENFZWG5LTONUW63SDN5WW2ZLOOQXTCNZYGIZDOMJRUZZGKYLTN5XKMYLVORUG64VFMV3GK3TUVRTG633UMVZF6Y3MNFRWW#discussioncomment-17822711>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/CCS7GK5BRYFK3FQV7K6IEWL5HGIDXAVCNFSNUABIKJSXA33TNF2G64TZHMZDCNBVHA3TCOJTHNCGS43DOVZXG2LPNY5TCMBVGAZDQMJZUF3AE>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/CCS7GKY6EDWZL4ZNS2J2JO35HGIDXA5CNFSNUABIM5UWIORPF5TWS5BNNB2WEL2ENFZWG5LTONUW63SDN5WW2ZLOOQXTCNZYGIZDOMJRUZZGKYLTN5XKMYLVORUG64VFMV3GK3TUVJTG633UMVZF62LPOM>
and Android
<https://github.com/notifications/mobile/android/CCS7GK4S7XZATDWLCVCYMQL5HGIDXA5CNFSNUABIM5UWIORPF5TWS5BNNB2WEL2ENFZWG5LTONUW63SDN5WW2ZLOOQXTCNZYGIZDOMJRUZZGKYLTN5XKMYLVORUG64VFMV3GK3TUVZTG633UMVZF6YLOMRZG62LE>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
|
Your post already rules out most of what a checklist would suggest, so here are four things that split the remaining cases apart rather than another list. 1. The SQL Editor test does not reproduce the real request
begin;
select set_config('request.jwt.claims', '{"role":"anon"}', true);
set local role anon;
insert into storage.objects (bucket_id, name, owner, owner_id, metadata)
values ('product-photos', 'diag/test.txt', null, null, '{}'::jsonb);
rollback;If that fails where your earlier test passed, the difference is the claims, not the grants. 2. Check for a RESTRICTIVE policy Permissive policies OR together, but a restrictive policy ANDs — so one restrictive policy evaluating false blocks the insert regardless of how many select pol.polname,
pol.polcmd,
pol.polpermissive,
array(select rolname from pg_roles where oid = any(pol.polroles)) as roles,
pg_get_expr(pol.polqual, pol.polrelid) as using_expr,
pg_get_expr(pol.polwithcheck, pol.polrelid) as with_check_expr
from pg_policy pol
where pol.polrelid = 'storage.objects'::regclass
order by pol.polpermissive, pol.polcmd, pol.polname;Any row with 3. If the call passes That makes the write a conflict-handling insert, and the conflicting path is checked against the UPDATE policy's 4. Which statement actually failed The 403 surfaces through storage-api, so the message you see has already lost the statement that produced it. Logs Explorer → Postgres logs, filtered to the minute of a failed upload, shows the real statement and the policy name involved. That distinguishes "Postgres rejected the row" from "storage-api rejected the request before it reached Postgres" — and given this project has the new key system enabled alongside the legacy keys, the second is worth eliminating explicitly. To @k1ng-arthur's question above: the publishable-key result is a genuinely useful data point here. If the publishable key succeeds where the legacy |
Uh oh!
There was an error while loading. Please reload this page.
Hi, I'm stuck on a Storage RLS issue that I can't explain after
extensive debugging. Would appreciate any insight.
Problem: Uploading a file via the JS client
(
supabase.storage.from(bucket).upload()) using theanonkeyfails with:
"StorageApiError: new row violates row-level security policy" (403)
What I've already verified:
storage.objectsfor INSERT, rolepublic/anon,with_check = true — confirmed via pg_policies query
storage.bucketsfor SELECT, role public, using = truegrant select, insert, update on storage.objects to anon;grant select on storage.buckets to anon;grant usage on schema storage to anon;full INSERT/SELECT/UPDATE/DELETE on both objects and buckets
(
set role anon; insert into storage.objects (...)) succeedswith no errors
apikeyandAuthorization: Bearerheaders, matching my actual anon key(decoded JWT confirms role: "anon")
under the 50MB limit
service_rolekey instead ofanonfor the exactsame upload succeeds immediately, confirming the app code itself
is correct
Additional context: this project appears to have the new API
key system (publishable/secret) enabled, alongside legacy
anon/service_role. Given the "early preview" status mentioned in
Supabase's key migration docs, I wonder if this is a Storage-specific
bug related to that transition, since raw SQL as anon succeeds but
the Storage API consistently rejects the anon JWT for uploads
specifically.
Project ref: fakklaqisfofkpagxotk
Given the raw SQL insert as
anonsucceeds but the Storage APIrejects the identical operation, I suspect something at the Storage
API layer isn't picking up the policies/grants correctly. Any ideas
on what else to check?
All reactions