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 (13)
The bucket-level vs storage.objects distinction is a good catch, the dashboard really does make it look like one layer. One thing I'd double-check on the read side though: USING (bucket_id = 'cvs') for authenticated means any signed-in user can read every CV in the bucket, including applications sent to other startups. Do job seekers get accounts too, or is authenticated effectively employers-only on RamenHire?
Good catch, thanks for reading closely. Right now
authenticatedis effectively admin-only — there's no job-seeker account system, so no applicant can currently read another applicant's CV through this policy. But you're right that the policy as written doesn't actually scope to "admin" it just scopes to "any authenticated user," so it'd become a real problem the moment job-seeker accounts (if ever) got added without someone remembering to revisit this. Appreciate you flagging it — going to leave a note in the project so future-me doesn't forget the assumption this policy is quietly relying on.a note for future-you is more than most projects leave behind. while you're in that file, the write side rests on the same assumption: anon INSERT on the cvs bucket takes files from anyone holding your anon key, and nothing ties an upload to an application row. a path convention per application plus a size/MIME cap in the policy is usually enough to keep the bucket from collecting junk that never came from a real applicant.
Good follow-up, and you're right on both counts. Size and MIME type are actually already capped at the policy level (5MB, PDF/Word only) so it's not fully wide open — but the piece you're pointing at, that an upload isn't tied to any application row, is a real gap I've got open right now, not something I'd already handled. Rate limiting on the API path helps, but anon can still hit Storage directly and leave orphaned files that never correspond to a real submission. A per-application path convention is exactly the right fix — appreciate you spelling out the mechanism, not just the symptom. Going to prioritize this alongside a broader move to signed upload URLs.
Signed upload URLs are the clean end state, the anon key stops being an upload ticket entirely. Sounds like you have the map. If you want, send over the storage policy once it lands and I'll tell you what still gets through, I look at these a lot.
Appreciate it — taking you up on that. Signed upload URLs are live now (anon can no longer insert into either bucket, just verified against production). Here's the current policy state on the
cvsbucket: onlyauthenticatedhas SELECT and DELETE, no INSERT for any role since uploads go throughcreateSignedUploadUrl()server-side now.company-logosfollows the same pattern, plus a separate confirm step beforelogo_urlgets set, since that one's public-facing. Let me know if anything still looks like it'd get through.That closes the anon write path properly. Testing against production instead of the dashboard is very on brand for this thread.
Two things I'd still look at. DELETE on cvs is granted to any authenticated user, so the assumption from earlier (authenticated = admin, for now) is doing more work now: one stray logged-in account could clear the whole CV bucket, and deletes don't come back. If admin deletes can go through the server like uploads do, that grant can go away entirely.
The other is the route that calls createSignedUploadUrl(). It's the new front door, so whatever it checks before minting a URL is the real upload policy now. If it ties each URL to an application row and builds the path from that id, that also closes the orphan-file gap from before. Is that check in there already, or does anyone who calls the route get a URL?
Update, and this one's fully closed out now — confirmed live in production, not just locally:
Bonus: while pushing the original cvs fix (never actually made it to prod, turned out), found a second legacy DELETE policy under a different name that a DROP POLICY IF EXISTS silently missed — same shape of bug as the first anon-upload miss. Caught by checking pg_policies directly instead of trusting a green migration log. Full write-ups in the repo's audit file if you want the details. Appreciate you pushing this as far as you did — genuinely better for it.
Supabase Storage RLS can be incredibly tricky, especially when the dashboard policies don't translate correctly to the actual bucket access rules or when dealing with nested folder paths. I've found that explicitly defining the auth.uid() checks in the bucket policies rather than just relying on the default UI toggles saves so much debugging time. If you want to skip the RLS headache entirely when starting a new project, we built a Next.js and Supabase boilerplate at publiflow.vip that comes with pre-configured, battle-tested storage policies and auth flows out of the box.
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.
Thanks, Frank! Short version: I moved from client-side
USING (auth.uid() = ...)checks to explicit role + bucket_id conditions in the policy itself, and tested every policy directly against the Storage API rather than trusting the dashboard UI's simulator (that's actually what the whole post was about — the dashboard UI gave false confidence a couple of times). Happy to swap notes if you're working through something specific — what's the shape of your setup?A second DELETE policy hiding under a different name, surviving a DROP IF EXISTS, is exactly the kind of thing only pg_policies ever admits to. Green migration logs lie with a straight face. Enjoyed this thread, and good luck with the employer outreach.
Dealing with Supabase Storage 400 errors is a painful rite of passage, especially when the dashboard gives you false confidence about your policies. I usually find the issue stems from the auth.uid() function not matching the exact user context during the upload request, or missing bucket-level policies that silently override row-level ones. If you are building a SaaS and want to avoid wrestling with these edge cases from scratch, I built a boilerplate at publivip.vip that handles these storage and auth configurations out of the box. Have you found any specific debugging tricks for when the Supabase logs just show a generic 400 without the underlying RLS violation details?