Supabase Storage Overview
Supabase Storage is built on top of PostgreSQL and uses the same RLS system that protects your database tables. Storage metadata lives in the storage schema, and bucket/object access is controlled through RLS policies on storage.objects and storage.buckets. This gives you fine-grained control, but also means misconfigurations follow the same patterns as database RLS mistakes.
Public vs. Private Buckets
Supabase has two bucket types:
- Public buckets: Files are accessible via a direct URL without authentication. Suitable for assets like logos, public images, or marketing files.
- Private buckets: Files require a signed URL or an authenticated request. Use for user uploads, documents, and any sensitive content.
-- Create a private bucket (default)
INSERT INTO storage.buckets (id, name, public)
VALUES ('user-documents', 'user-documents', false);
-- Create a public bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('public-assets', 'public-assets', true);
The Public Bucket Trap
A common mistake is making a bucket public for convenience during development and forgetting to change it. Public buckets expose all files to anyone who can guess or enumerate the file path.
-- Anyone can access this without authentication:
https://your-project.supabase.co/storage/v1/object/public/user-documents/user123/passport.pdf
Rule of thumb: Unless the content is truly meant for public consumption, use private buckets with signed URLs.
Writing Storage Policies
Storage policies work exactly like table RLS policies. They are defined on the storage.objects table:
-- Allow users to upload to their own folder
CREATE POLICY "Users can upload to own folder"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'user-documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to read their own files
CREATE POLICY "Users can read own files"
ON storage.objects
FOR SELECT
USING (
bucket_id = 'user-documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Allow users to delete their own files
CREATE POLICY "Users can delete own files"
ON storage.objects
FOR DELETE
USING (
bucket_id = 'user-documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);
The pattern (storage.foldername(name))[1] extracts the first folder segment from the object path, allowing you to use the user's ID as a folder name.
File Type Validation
Supabase Storage allows you to restrict file types at the bucket level:
UPDATE storage.buckets
SET allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
WHERE id = 'user-documents';
You can also enforce size limits:
UPDATE storage.buckets
SET file_size_limit = 5242880 -- 5 MB
WHERE id = 'user-documents';
Always set both. Without file type restrictions, attackers can upload executable files or HTML files that could be served and execute in the context of your domain (leading to XSS via stored files).
Signed URLs for Private Access
For private buckets, generate time-limited signed URLs on the server side:
// In an Edge Function or server-side code
const { data, error } = await supabase
.storage
.from('user-documents')
.createSignedUrl('user123/report.pdf', 3600); // 1 hour expiry
// data.signedUrl is safe to return to the client
Keep expiry times as short as practical. For one-time downloads, consider very short windows (60-300 seconds).
Preventing Path Traversal
Ensure your application sanitizes file names before uploading. A malicious file name like ../../other-user/private.txt could potentially write to another user's folder if not properly handled:
function sanitizeFileName(name: string): string {
return name
.replace(/\.\./g, '') // Remove path traversal
.replace(/[^a-zA-Z0-9._-]/g, '_') // Whitelist safe characters
.substring(0, 255); // Limit length
}
While Supabase's RLS policies should catch this at the database level, defense in depth at the application layer is still important.
Common Vulnerabilities
1. No Policies on storage.objects
If RLS is enabled on storage.objects but no policies exist, no one can access files through the API (except the service role). If RLS is disabled, anyone can access all files. Check your RLS status:
SELECT relname, relrowsecurity
FROM pg_class
WHERE relname = 'objects'
AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'storage');
2. Overly Broad Upload Policies
-- BAD: Any authenticated user can upload anywhere
CREATE POLICY "Authenticated uploads"
ON storage.objects FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- GOOD: Scoped to user's own folder
CREATE POLICY "Users upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
auth.uid()::text = (storage.foldername(name))[1]
);
3. Missing CORS Configuration
If your storage bucket CORS configuration is too permissive, it can be accessed from any origin. Configure CORS at the Supabase project level to only allow your application domains.
Audit with AuditYour.app
AuditYour.app checks your storage configuration automatically, including bucket visibility, missing policies, file type restrictions, and size limits. Run a scan to identify storage-related vulnerabilities before they are exploited.
Scan your app for this vulnerability
AuditYourApp automatically detects security misconfigurations in Supabase and Firebase projects. Get actionable remediation in minutes.
Run Free ScanRelated
guides
Complete Guide to Supabase Row Level Security
Deep dive into RLS policies, patterns, and common pitfalls
guides
Supabase Anonymous Key Security
Understanding anon key risks and proper usage
guides
Supabase Database Security Best Practices
Comprehensive Postgres/Supabase DB hardening guide
guides
Hardening Supabase Edge Functions
Best practices for secure Edge Function development