Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Supabase Storage: multipart uploads and `x-upsert` rejected with a misleading RLS error

Open
#1,241 3 comments 0 reactions 1 assignee View on GitHub

Maintainers usually reply within 1 day

@ferhatelmas is already working on this.

Since Jul 29, 2026.

Assessment

This issue has not been assessed yet.

Description

bug

Summary

For our project (ref: <PROJECT_REF>), Storage upload requests started failing sometime around July 3, 2026 with:

{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy"}

This error is misleading — the actual cause has nothing to do with the RLS policy itself. Two independent, unrelated request shapes trigger it:

  1. Any multipart/form-data upload — i.e. every call to the storage-js/Dart storage_client SDK's upload() / uploadBinary() methods, since they always send multipart requests.
  2. Any request with the x-upsert: true header — even against a path that has never been used before, with no existing object to conflict with.

A raw binary body POST with no x-upsert header to a path that has never existed succeeds instantly and reliably. This was true across every bucket we tested (posts, avatars, groups, service-providers, feedbacks).

Why this is misleading

The error message and 403 status strongly suggest an RLS/auth problem. We spent significant time (verified end-to-end) ruling that out before finding the real cause:

  • Confirmed the JWT was valid, non-expired, and correctly scoped (role: authenticated, sub matching the folder owner) — both by decoding it and by using the exact same token in a request that succeeded.
  • Confirmed the RLS policy's storage.foldername() logic evaluates correctly against the exact object path used, via a direct SQL query against auth.uid()/storage.foldername().
  • Confirmed the SDK's client-side headers (Authorization, apikey) were correctly populated at the moment of the request.
  • Only after isolating variables one at a time (multipart vs. raw body, x-upsert: true vs. false vs. omitted, fresh token vs. reused token) did the actual trigger become clear: it's the request shape, not the identity making the request.

Given the message says "row-level security policy," most developers (including us, initially) will spend hours auditing RLS policies, session/token handling, and auth configuration — none of which is the actual problem.

Reproduction

Replace <PROJECT_REF>, <ANON_KEY>, and <USER_ACCESS_TOKEN> with real values for a test project/user. <USER_ID> should match the authenticated user's auth.uid(), and the bucket should have a standard "users can upload/update/delete under their own auth.uid() folder" RLS policy set (INSERT/UPDATE/DELETE WITH CHECK/USING (storage.foldername(name))[1] = auth.uid()::text).

✅ Works — raw binary body, no upsert:

curl -X POST "https://<PROJECT_REF>.supabase.co/storage/v1/object/<bucket>/<USER_ID>/test.jpg" \
  -H "apikey: <ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @test.jpg
# => 200, {"Key": "...", "Id": "..."}

❌ Fails — identical request, only difference is x-upsert: true added, target path has never existed:

curl -X POST "https://<PROJECT_REF>.supabase.co/storage/v1/object/<bucket>/<USER_ID>/brand-new-path.jpg" \
  -H "apikey: <ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: image/jpeg" \
  -H "x-upsert: true" \
  --data-binary @test.jpg
# => 400, {"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy"}

❌ Fails — same token, multipart/form-data body (i.e. what storage_client's upload()/uploadBinary() actually send internally), no upsert:

curl -X POST "https://<PROJECT_REF>.supabase.co/storage/v1/object/<bucket>/<USER_ID>/multipart-test.jpg" \
  -H "apikey: <ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -F "[email protected];type=image/jpeg;filename=" \
  -F "cacheControl=3600"
# => 400, {"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy"}

❌ Deleting an object that genuinely exists and is genuinely owned by the requesting user also fails, with a different message:

curl -X DELETE "https://<PROJECT_REF>.supabase.co/storage/v1/object/<bucket>/<USER_ID>/existing-object.jpg" \
  -H "apikey: <ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>"
# => 400, {"statusCode":"403","error":"Unauthorized","message":"Access denied"}

We confirmed via direct SQL that storage.objects.owner and owner_id were correctly set to the requesting user's ID for the object in question, so this isn't an ownership mismatch either.

Additional data points

  • We tried both storage_client 2.5.1 and 2.6.0 (the latest at time of writing) — both send multipart/form-data for upload()/uploadBinary() with an identical (empty) field name, and both hit the same failure. Upgrading the package version did not change the behavior.
  • The last successful upload we could find in our posts bucket (via storage.objects.created_at) was 2026-07-03, coincidentally the same day storage_client 2.6.0 was published on pub.dev — though we found no code difference between 2.5.1 and 2.6.0 that would explain it, so we suspect this is a backend/infrastructure-side change on Supabase's end rather than an SDK issue.
  • The requests that fail are consistently fast (not a timeout) — the server actively evaluates and rejects them, it isn't hanging or retrying.

Impact

Every image/file upload path in our app (Flutter, using supabase_flutter/storage_client) was broken — confirmed across 5 separate storage buckets covering multiple upload flows. Any app relying on the SDK's standard upload()/uploadBinary() methods, or on upsert: true, would hit this on this project.

Workaround in place

We now bypass the SDK's storage client entirely for uploads: raw binary POST with http.post(), to a freshly-generated (never-before-used, timestamped) path, with no x-upsert header. This works reliably. We're not attempting to overwrite/replace existing objects anymore (switched fixed filenames like avatar.jpg to timestamped ones) specifically to avoid the x-upsert/delete issues.

Ask

  1. Can you confirm whether something changed server-side around July 3, 2026 for storage multipart parsing and/or x-upsert handling?
  2. Could the new row violates row-level security policy / generic Access denied messages be made more specific in these cases, so this is diagnosable without this level of manual isolation?
  3. Is there a supported way to use upsert: true reliably, or is raw binary + delete/replace-with-new-path the recommended pattern going forward?

Project ref: <PROJECT_REF> (will provide directly to Supabase support/staff, not posted publicly)

Dominant language
TypeScript
Stars
1.3k
Forks
314
Avg merge
1d 11h
Merged PRs (30d)
89

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from supabase/storage

All issues in supabase/storage

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.