DEV Community

Lior Karaev
Lior Karaev

Posted on

How I Fixed Supabase Storage RLS Errors That the Dashboard UI Couldn't Solve

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)
Enter fullscreen mode Exit fullscreen mode

Then after digging into the actual response:

{"statusCode":"403","error":"Unauthorized",
"message":"Invalid Compact JWS"}

Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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:

  1. Open SQL Editor
  2. Drop the UI-created policies
  3. Recreate them directly on storage.objects
  4. 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.

🍜 ramenhire.com

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)

Collapse
 
vollos profile image
Pon

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?

Collapse
 
l_build profile image
Lior Karaev

Good catch, thanks for reading closely. Right now authenticated is 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.

Collapse
 
vollos profile image
Pon

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.

Thread Thread
 
l_build profile image
Lior Karaev

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.

Thread Thread
 
vollos profile image
Pon

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.

Thread Thread
 
l_build profile image
Lior Karaev

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 cvs bucket: only authenticated has SELECT and DELETE, no INSERT for any role since uploads go through createSignedUploadUrl() server-side now. company-logos follows the same pattern, plus a separate confirm step before logo_url gets set, since that one's public-facing. Let me know if anything still looks like it'd get through.

Thread Thread
 
vollos profile image
Pon

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?

Thread Thread
 
l_build profile image
Lior Karaev

Update, and this one's fully closed out now — confirmed live in production, not just locally:

  • Both cvs and company-logos now reject DELETE for any authenticated user (403), tested against production with a real disposable auth account rather than a crafted token.
  • resend-verification now rate-limits for real — tripped it live, 6th request got a 429.
  • The HTML-escaping fix actually shipped: sent a real application through the live site with a script/onerror/fake-link payload in the name field, and the resulting email rendered it as inert, garbled text — no image tag fired, no fake "click here" link, just visibly broken escaped characters. (One unrelated fun detail: Gmail auto-linkifies any bare URL it finds in plain text regardless of escaping, so the raw domain still showed up clickable — but it's sitting in obvious garbage text now instead of looking like a real button, which is the actual thing that mattered.)
  • Honeypot's now checked server-side too, confirmed a filled one silently no-ops.

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.

Collapse
 
publiflow profile image
PubliFlow

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.

Collapse
 
frank_signorini profile image
Frank

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.

Collapse
 
l_build profile image
Lior Karaev

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?

Collapse
 
vollos profile image
Pon

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.

Collapse
 
publiflow profile image
PubliFlow

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?