FixVibe

// docs / baas security / supabase storage

Supabase storage bucket security checklist: 22 items

Supabase Storage is a thin wrapper around an S3-compatible bucket plus the same Row-Level Security model as the database. That means the same RLS pitfalls that affect tables affect file access โ€” and a few storage-specific ones that show up when AI coding tools wire up uploads. This checklist is 22 items across five sections: bucket configuration, RLS policies, upload validation, signed URLs, and operational hygiene. Each is verifiable in under 15 minutes.

Each item below is essential. For the underlying RLS mechanics, see <rlsArticle>Supabase RLS scanner</rlsArticle>. For the key-exposure class adjacent to storage, see <serviceKeyArticle>Supabase service role key exposed in JavaScript</serviceKeyArticle>.

Bucket configuration

Start with the right defaults. A misconfigured bucket leaks files whether your RLS is correct or not.

  1. <strong>Default every bucket to private.</strong> In the Supabase Dashboard โ†’ Storage โ†’ Buckets, set the <em>Public bucket</em> toggle to off unless you have an explicit reason (marketing assets, public avatars with no PII). Public buckets bypass RLS for read operations โ€” anyone with the bucket name can list and download.
  2. <strong>Set a hard file size limit on every bucket.</strong> Dashboard โ†’ Bucket settings โ†’ File size limit. 50 MB is a sensible default for user uploads; raise it deliberately for video / large-file use cases. Without a limit, a single malicious upload can exhaust your storage quota or your monthly bandwidth.
  3. <strong>Restrict allowed MIME types per bucket.</strong> Allowed MIME types list โ€” explicit allowlist, not blocklist. <code>image/jpeg</code>, <code>image/png</code>, <code>image/webp</code> for image-only buckets. Never allow <code>text/html</code>, <code>application/javascript</code>, or <code>image/svg+xml</code> in a user-content bucket โ€” they execute in the browser when served via signed URL.
  4. <strong>Use one bucket per content type, not one shared bucket.</strong> Per-bucket settings (size, MIME types, RLS policies) are the granularity you have. A <code>user-avatars</code> bucket, a <code>document-uploads</code> bucket, and a <code>public-assets</code> bucket are easier to lock down than one mixed bucket.
  5. <strong>Verify CORS configuration if frontend uploads.</strong> If users upload directly from the browser to a signed URL, the bucket CORS must list your production origin. <code>*</code> is acceptable for public buckets only โ€” never for buckets containing user PII.

RLS policies on storage.objects

Supabase Storage stores file metadata in the <code>storage.objects</code> table. RLS on that table controls who can read, upload, update, or delete files. Without RLS, the bucket's public/private flag is your only protection.

  1. <strong>Confirm RLS is enabled on storage.objects.</strong> <code>SELECT rowsecurity FROM pg_tables WHERE schemaname = 'storage' AND tablename = 'objects';</code> must return <code>true</code>. Supabase enables it by default on new projects; verify it has not been disabled.
  2. <strong>Write a SELECT policy scoped to <code>auth.uid()</code> for private buckets.</strong> <code>CREATE POLICY "users_read_own_files" ON storage.objects FOR SELECT USING (auth.uid()::text = (storage.foldername(name))[1]);</code>. The convention is to store files under <code>[user-id]/[filename]</code> and use <code>storage.foldername()</code> to extract the owner from the path.
  3. <strong>Write an INSERT policy that enforces the same path convention.</strong> <code>CREATE POLICY "users_upload_own" ON storage.objects FOR INSERT WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);</code>. Without WITH CHECK, an authenticated user can upload into another user's folder.
  4. <strong>Add UPDATE and DELETE policies if your app supports file edits or deletes.</strong> Each command needs its own policy. Skipping DELETE means authenticated users cannot remove their own files; skipping UPDATE means file overwrites silently fail.
  5. <strong>Test cross-user access in two browser sessions.</strong> Sign in as User A, upload a file, copy the path. Sign in as User B in another browser, try to fetch the file via the REST API. The response must be <code>403</code> or <code>404</code>, never <code>200</code>.
sql
-- Confirm RLS on storage.objects
SELECT rowsecurity
FROM   pg_tables
WHERE  schemaname = 'storage' AND tablename = 'objects';

-- SELECT policy: scope reads to the owning user's folder.
CREATE POLICY "users_read_own_files"
  ON storage.objects
  FOR SELECT
  USING (auth.uid()::text = (storage.foldername(name))[1]);

-- INSERT policy: enforce the [user-id]/[filename] path convention.
CREATE POLICY "users_upload_own"
  ON storage.objects
  FOR INSERT
  WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);

Upload validation

Validate every upload server-side, even when the bucket has MIME and size constraints. AI coding tools generate client-only validation by default; that protects nothing.

  1. <strong>Re-check MIME type server-side from the file's actual bytes, not the <code>Content-Type</code> header.</strong> Use a library like <code>file-type</code> (Node) or magic-byte sniffing. An attacker can claim <code>Content-Type: image/jpeg</code> on a file that is actually a polyglot HTML / JavaScript payload.
  2. <strong>Strip EXIF metadata from uploaded images.</strong> EXIF can contain GPS coordinates, device serial numbers, and timestamps. Use <code>sharp</code> with <code>.withMetadata(false)</code> or <code>exif-parser</code> to strip before storage.
  3. <strong>Reject SVGs that contain <code>script</code> tags or <code>onload</code> handlers.</strong> SVG is XML โ€” and many AI-generated apps allow SVG uploads as "just an image." Use <code>DOMPurify</code> server-side or refuse SVG uploads entirely.
  4. <strong>Use deterministic, unguessable filenames.</strong> Don't preserve the original filename. Use a UUID or a hash of the file contents. Original filenames leak ("<code>passport_scan_2024_01_15.jpg</code>") and predictable names enable enumeration.

Signed URLs

Signed URLs are how clients access private buckets. The expiry, the bucket scope, and what gets logged matter.

  1. <strong>Default signed-URL expiry to 1 hour or less.</strong> The Supabase JS SDK's <code>createSignedUrl(path, expiresIn)</code> takes seconds. Never use values like <code>31536000</code> (one year) โ€” the URL becomes a permanent semi-public link.
  2. <strong>Never store signed URLs in your database.</strong> Generate fresh ones server-side on every request. A stored signed URL with a 1-year expiry that leaks via a database dump grants long-term access.
  3. <strong>Log signed-URL generation, not just file uploads.</strong> If you suspect a compromise later, you need to know who generated which URL when. Log <code>auth.uid()</code> + bucket + object path + timestamp.
  4. <strong>Use the <code>downloadAs</code> option when serving user-uploaded files.</strong> <code>createSignedUrl(path, expiresIn, '{' download: ''.jpg'' '}')</code> forces a <code>Content-Disposition: attachment</code> header so the file downloads instead of rendering โ€” defeats the HTML / SVG / HTML-in-PDF execution class.

Operational hygiene

Storage configuration drifts over time. These four operational items keep the surface tight.

  1. <strong>Audit buckets quarterly.</strong> Dashboard โ†’ Storage โ†’ Buckets. Confirm public/private state and MIME-type lists match what the app expects. Buckets created "temporarily" become permanent if no one removes them.
  2. <strong>Monitor anonymous list operations.</strong> Storage logs (Dashboard โ†’ Logs โ†’ Storage) record <code>LIST</code> requests. A spike of anonymous list requests against a private bucket means someone is probing it from the outside.
  3. <strong>Set a retention policy for ephemeral uploads.</strong> Temp buckets (image preview, draft uploads) should auto-delete after 24-72 hours via a scheduled function. Indefinite retention is a liability under GDPR / CCPA data-minimisation obligations.
  4. <strong>Run a FixVibe scan monthly.</strong> The <code>baas.supabase-storage-public</code> check probes for buckets that respond to anonymous <code>GET</code> + <code>LIST</code>. New buckets get added; old ones change visibility โ€” only continuous scanning catches the drift.

Next steps

Run a FixVibe scan against your production URL โ€” anonymous storage listings show up under <code>baas.supabase-storage-public</code>. Pair this checklist with <rlsArticle>Supabase RLS scanner</rlsArticle> for the table layer and <serviceKeyArticle>Supabase service role key exposed in JavaScript</serviceKeyArticle> for the key-exposure adjacency. For storage misconfigurations across other BaaS providers, see <baasScannerArticle>BaaS misconfiguration scanner</baasScannerArticle>.

// scan your baas surface

Find the open table before someone else does.

Drop in a production URL. FixVibe enumerates the BaaS providers your app talks to, fingerprints their public endpoints, and reports what an unauthenticated client can read or write. Free, no install, no card.

  • Free tier โ€” 3 scans / month, no signup card.
  • Passive BaaS fingerprinting โ€” no domain verification needed.
  • Supabase, Firebase, Clerk, Auth0, Appwrite, and more.
  • AI fix prompts on every finding โ€” paste back into Cursor / Claude Code.
Supabase storage bucket security checklist: 22 items โ€” Docs ยท FixVibe