If you've ever spent hours staring at a 400 error on
a Supabase Storage upload despite having RLS policies
correctly configured in the dashboard — this is for you.
The Setup
I'm building RamenHire 🍜 — a job board for bootstrapped
startups. One of the features is CV upload: job seekers
drag and drop their CV directly in an apply modal, it
uploads to a private Supabase Storage bucket, and the
file path gets saved to the applications table.
Simple enough. Except it wasn't.
The Error
POST https://[project].supabase.co/storage/v1/object/cvs/...
400 (Bad Request)
Then after digging into the actual response:
{"statusCode":"403","error":"Unauthorized",
"message":"Invalid Compact JWS"}
What I Tried First (That Didn't Work)
I went straight to the Supabase dashboard and added
RLS policies via the Storage UI:
- Allow anonymous uploads (INSERT, anon role)
- Allow authenticated reads (SELECT, authenticated role)
- Allow authenticated deletes (DELETE, authenticated role)
Looked correct. Saved. Tested. Still 400.
I checked the bucket settings — private bucket, no file
size limit, no MIME type restrictions. All fine.
I verified the Supabase client was initialized correctly
with the right environment variables. Fine.
I checked the file path format, sanitized the filename,
added contentType to the upload call. Still failing.
The Actual Problem
Here's what took me too long to figure out:
The Supabase Storage dashboard UI creates policies on
the bucket level — but RLS enforcement happens on
storage.objects, not on the bucket itself.
The UI policies weren't actually applying to the right
place. They looked correct in the dashboard but weren't
being enforced at the level Supabase actually checks
during uploads.
The Fix
Stop using the UI. Go to the SQL Editor and create
the policies directly on storage.objects:
-- Drop any existing UI-created policies first
DROP POLICY IF EXISTS "Allow anonymous uploads" ON storage.objects;
DROP POLICY IF EXISTS "Allow authenticated reads" ON storage.objects;
DROP POLICY IF EXISTS "Allow authenticated deletes" ON storage.objects;
-- Allow anonymous users to upload to the cvs bucket
CREATE POLICY "Allow anonymous uploads"
ON storage.objects
FOR INSERT
TO anon
WITH CHECK (bucket_id = 'cvs');
-- Allow authenticated users to read
CREATE POLICY "Allow authenticated reads"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'cvs');
-- Allow authenticated users to delete
CREATE POLICY "Allow authenticated deletes"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'cvs');
Run this in the SQL Editor. Test your upload immediately.
It worked for me on the first try after hours of
debugging the wrong thing.
Why This Happens
Supabase Storage is built on top of Postgres. The actual
files and their metadata are stored in the storage.objects
table in the storage schema. When you upload a file,
Supabase checks RLS policies on that table — not on
the bucket configuration.
The dashboard UI abstracts this away, but the abstraction
is leaky. Policies created via the UI sometimes don't
propagate correctly to storage.objects, especially for
the anon role on INSERT operations.
Going directly to SQL bypasses the abstraction entirely
and creates the policies exactly where Supabase needs them.
The Key Difference
UI-created storage policy:
Targets: bucket configuration layer
Result: sometimes works, sometimes doesn't
SQL-created policy on storage.objects:
Targets: the actual RLS table Supabase checks
Result: always works
Bonus: How To Verify Your Policies Are Actually Applied
Run this in the SQL Editor to confirm your policies
exist on the right table:
SELECT policyname, cmd, roles, qual, with_check
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects';
If you see your policies here — they're real and will work.
If you only see them in the Storage UI but not here —
they're not actually enforced.
TL;DR
If Supabase Storage uploads return 400 or 403 errors
despite correct-looking RLS policies in the dashboard:
- Open SQL Editor
- Drop the UI-created policies
- Recreate them directly on
storage.objects - Test immediately
The UI is convenient but unreliable for storage RLS.
SQL is the source of truth.
Building RamenHire in public — a job board exclusively
for bootstrapped, profitable startups.
If you found this useful or hit the same issue,
drop a comment — would love to know how common
this actually is.
Top comments (1)
How did you handle row level security rules for storage uploads, did you use a specific policy or function to bypass the 400 error? I'd love to swap ideas on this, been struggling with similar issues.