// 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 Supabase RLS scanner. For the key-exposure class adjacent to storage, see Supabase service role key exposed in JavaScript.
Bucket configuration
Start with the right defaults. A misconfigured bucket leaks files whether your RLS is correct or not.
- Default every bucket to private. In the Supabase Dashboard โ Storage โ Buckets, set the Public bucket 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.
- Set a hard file size limit on every bucket. 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.
- Restrict allowed MIME types per bucket. Allowed MIME types list โ explicit allowlist, not blocklist.
image/jpeg,image/png,image/webpfor image-only buckets. Never allowtext/html,application/javascript, orimage/svg+xmlin a user-content bucket โ they execute in the browser when served via signed URL. - Use one bucket per content type, not one shared bucket. Per-bucket settings (size, MIME types, RLS policies) are the granularity you have. A
user-avatarsbucket, adocument-uploadsbucket, and apublic-assetsbucket are easier to lock down than one mixed bucket. - Verify CORS configuration if frontend uploads. If users upload directly from the browser to a signed URL, the bucket CORS must list your production origin.
*is acceptable for public buckets only โ never for buckets containing user PII.
RLS policies on storage.objects
Supabase Storage stores file metadata in the storage.objects 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.
- Confirm RLS is enabled on storage.objects.
SELECT rowsecurity FROM pg_tables WHERE schemaname = 'storage' AND tablename = 'objects';must returntrue. Supabase enables it by default on new projects; verify it has not been disabled. - Write a SELECT policy scoped to
auth.uid()for private buckets.CREATE POLICY "users_read_own_files" ON storage.objects FOR SELECT USING (auth.uid()::text = (storage.foldername(name))[1]);. The convention is to store files under[user-id]/[filename]and usestorage.foldername()to extract the owner from the path. - Write an INSERT policy that enforces the same path convention.
CREATE POLICY "users_upload_own" ON storage.objects FOR INSERT WITH CHECK (auth.uid()::text = (storage.foldername(name))[1]);. Without WITH CHECK, an authenticated user can upload into another user's folder. - Add UPDATE and DELETE policies if your app supports file edits or deletes. Each command needs its own policy. Skipping DELETE means authenticated users cannot remove their own files; skipping UPDATE means file overwrites silently fail.
- Test cross-user access in two browser sessions. 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
403or404, never200.
-- 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.
- Re-check MIME type server-side from the file's actual bytes, not the
Content-Typeheader. Use a library likefile-type(Node) or magic-byte sniffing. An attacker can claimContent-Type: image/jpegon a file that is actually a polyglot HTML / JavaScript payload. - Strip EXIF metadata from uploaded images. EXIF can contain GPS coordinates, device serial numbers, and timestamps. Use
sharpwith.withMetadata(false)orexif-parserto strip before storage. - Reject SVGs that contain
scripttags oronloadhandlers. SVG is XML โ and many AI-generated apps allow SVG uploads as "just an image." UseDOMPurifyserver-side or refuse SVG uploads entirely. - Use deterministic, unguessable filenames. Don't preserve the original filename. Use a UUID or a hash of the file contents. Original filenames leak ("
passport_scan_2024_01_15.jpg") 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.
- Default signed-URL expiry to 1 hour or less. The Supabase JS SDK's
createSignedUrl(path, expiresIn)takes seconds. Never use values like31536000(one year) โ the URL becomes a permanent semi-public link. - Never store signed URLs in your database. 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.
- Log signed-URL generation, not just file uploads. If you suspect a compromise later, you need to know who generated which URL when. Log
auth.uid()+ bucket + object path + timestamp. - Use the
downloadAsoption when serving user-uploaded files.createSignedUrl(path, expiresIn, { download: '.jpg' })forces aContent-Disposition: attachmentheader 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.
- Audit buckets quarterly. 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.
- Monitor anonymous list operations. Storage logs (Dashboard โ Logs โ Storage) record
LISTrequests. A spike of anonymous list requests against a private bucket means someone is probing it from the outside. - Set a retention policy for ephemeral uploads. 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.
- Run a FixVibe scan monthly. The
baas.supabase-storage-publiccheck probes for buckets that respond to anonymousGET+LIST. 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 baas.supabase-storage-public. Pair this checklist with Supabase RLS scanner for the table layer and Supabase service role key exposed in JavaScript for the key-exposure adjacency. For storage misconfigurations across other BaaS providers, see BaaS misconfiguration scanner.
