Zuletzt aktualisiert: August 2026
File Uploads gehören zu den häufigsten Features in modernen Web-Apps, aber die korrekte Implementierung mit Supabase Storage kann trickreich sein. Viele Entwickler kämpfen mit Permission-Fehlern, langsamen Uploads oder unsicheren Konfigurationen.
In diesem Artikel zeige ich dir Schritt für Schritt, wie du File Uploads mit Supabase Storage sicher und performant implementierst - von Bucket-Konfiguration und RLS-Policies über Upload und Download bis zur Production-reifen Frontend-Integration.
Was ist Supabase Storage?
Supabase Storage ist ein S3-kompatibles Object Storage System, das nahtlos in dein Supabase-Projekt integriert ist. Es bietet:
- Row Level Security für sichere File-Zugriffe
- Automatische Image-Optimierung und Transformation
- CDN-Integration für schnelle Auslieferung
- Resumable Uploads für große Dateien
- Webhook-Integration für automatische Verarbeitung
Schritt 1: Storage Bucket einrichten
Bucket erstellen
Gehe zu deinem Supabase Dashboard → Storage und erstelle einen neuen Bucket:
-- Option 1: Über das Dashboard (empfohlen für Anfänger)
-- Storage > Create bucket > "uploads" > Public/Private auswählen
-- Option 2: Per SQL
INSERT INTO storage.buckets (id, name, public)
VALUES ('uploads', 'uploads', false);
Wichtig: Setze public nur auf true, wenn alle Dateien öffentlich zugänglich sein sollen (z.B. für Produktbilder).
RLS Policies konfigurieren
Das ist der kritische Teil! Ohne korrekte RLS Policies funktioniert der Upload nicht:
-- Policy für Upload (INSERT)
CREATE POLICY "Authenticated users can upload files"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien lesen (SELECT)
CREATE POLICY "Users can view own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien löschen (DELETE)
CREATE POLICY "Users can delete own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien aktualisieren (UPDATE)
CREATE POLICY "Users can update own files"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
Erklärung: Diese Policies erlauben es Benutzern nur, Dateien in ihren eigenen Ordnern zu verwalten (Ordnername = User ID).
Derselbe Fehlertext – new row violates row-level security policy – taucht auch bei Tabellen-Inserts auf. Das ist eine andere Schicht (PostgREST SELECT nach INSERT). Fix: new row violates row-level security policy. Storage-Uploads brauchen INSERT und SELECT auf storage.objects – siehe Storage 403 RLS beim Upload.
Bucket Policies: Size-Limits, MIME-Types und Zugriff
RLS auf storage.objects entscheidet, wer eine Datei lesen oder schreiben darf. Bucket-Settings entscheiden, was der Bucket akzeptiert. Verwechselst du das, siehst du 413 / 415 und denkst, es sei ein Permission-Problem.
Bucket per SQL anlegen oder updaten
So richte ich einen privaten User-Upload-Bucket ein:
-- Privater Bucket, max. 10 MB, nur Bilder + PDF
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'uploads',
'uploads',
false,
10485760,
ARRAY['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
)
ON CONFLICT (id) DO UPDATE SET
public = EXCLUDED.public,
file_size_limit = EXCLUDED.file_size_limit,
allowed_mime_types = EXCLUDED.allowed_mime_types;
-- Bestehenden öffentlichen Avatar-Bucket enger ziehen
UPDATE storage.buckets
SET
public = true,
file_size_limit = 2 * 1024 * 1024,
allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/webp']
WHERE id = 'avatars';
Im Dashboard: Storage → Bucket → Configuration (Public-Toggle, Max File Size, Allowed MIME Types). SQL und Dashboard schreiben dieselbe Zeile in storage.buckets.
Welche Policies brauchst du wirklich?
| Operation | SQL-Command | Wann du sie brauchst |
|---|---|---|
| Upload | INSERT |
Immer – sonst 403 bei .upload() |
| Lesen / listen / Public URL | SELECT |
Immer – Storage nutzt RETURNING *; Listen und Signed URLs machen ebenfalls SELECT |
Überschreiben (upsert: true) |
UPDATE |
Avatare, Replace-in-Place-Pfade |
| Löschen | DELETE |
User darf eigene Dateien entfernen |
Public Bucket ≠ keine RLS. public = true überspringt nur den Signed-URL-Schritt beim Lesen. Uploads brauchen trotzdem INSERT (+ SELECT), außer du erlaubst bewusst die Rolle anon – bei User-Content solltest du das nicht tun.
Schritt 2: Frontend Implementation
Der Upload-Call (JavaScript)
Das ist die API, nach der die Leute suchen. Du brauchst einen eingeloggten User, wenn deine Policies authenticated + auth.uid() nutzen:
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in')
const path = `${user.id}/${file.name}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, {
cacheControl: '3600',
upsert: false,
contentType: file.type,
})
if (error) {
console.error(error.message, error.statusCode)
throw error
}
console.log('Stored at', data.path)
React/Next.js Upload Component
Hier ist eine vollständige, production-ready Upload-Komponente:
// components/FileUpload.tsx
import { useState, useRef } from 'react'
import { createClient } from '@supabase/supabase-js'
import { useUser } from '@/hooks/useUser' // dein Auth Hook
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
interface FileUploadProps {
onUploadComplete: (url: string) => void
allowedTypes?: string[]
maxSize?: number // in MB
bucket?: string
}
export default function FileUpload({
onUploadComplete,
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
maxSize = 5,
bucket = 'uploads'
}: FileUploadProps) {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { user } = useUser()
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !user) return
setError(null)
// Validierung
if (!allowedTypes.includes(file.type)) {
setError(`Dateityp nicht erlaubt. Erlaubt: ${allowedTypes.join(', ')}`)
return
}
if (file.size > maxSize * 1024 * 1024) {
setError(`Datei zu groß. Maximum: ${maxSize}MB`)
return
}
await uploadFile(file)
}
const uploadFile = async (file: File) => {
setUploading(true)
setUploadProgress(0)
try {
// Eindeutigen Dateinamen generieren
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload mit Progress Tracking
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
throw error
}
// Public URL generieren
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
onUploadComplete(publicUrl)
// Input zurücksetzen
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} catch (error: any) {
console.error('Upload error:', error)
setError(error.message || 'Upload fehlgeschlagen')
} finally {
setUploading(false)
setUploadProgress(0)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mb-2 text-sm text-gray-500">
<span className="font-semibold">Klicken zum Upload</span> oder Drag & Drop
</p>
<p className="text-xs text-gray-500">
{allowedTypes.join(', ')} (max. {maxSize}MB)
</p>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
accept={allowedTypes.join(',')}
disabled={uploading || !user}
/>
</label>
</div>
{uploading && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
{uploading && (
<p className="text-sm text-gray-600 text-center">
Uploading... {uploadProgress}%
</p>
)}
</div>
)
}
Usage Beispiel
// pages/profile.tsx
import FileUpload from '@/components/FileUpload'
export default function ProfilePage() {
const handleAvatarUpload = (url: string) => {
console.log('Avatar uploaded:', url)
// Update user profile mit neuer Avatar URL
updateUserProfile({ avatar_url: url })
}
return (
<div>
<h1>Profil bearbeiten</h1>
<FileUpload
onUploadComplete={handleAvatarUpload}
allowedTypes={['image/jpeg', 'image/png']}
maxSize={2}
bucket="avatars"
/>
</div>
)
}
Häufige Upload-Szenarien (Copy-Paste)
Das sind die Patterns, die ich in Production am häufigsten brauche. Nimm das, was zu deinem Use Case passt.
1. Avatar-Upload (Überschreiben erlaubt)
Ein fester Pfad pro User – überschreiben mit upsert: true:
const path = `${user.id}/avatar.jpg`
const { data, error } = await supabase.storage
.from('avatars')
.upload(path, file, {
upsert: true,
contentType: file.type,
cacheControl: '3600',
})
if (error) throw error
// CDN-Cache nach Überschreiben bustens
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(path)
const avatarUrl = `${publicUrl}?t=${Date.now()}`
Wichtig: Upsert braucht zusätzlich eine UPDATE-Policy zu INSERT + SELECT. Siehe auch Storage 403 RLS beim Upload.
2. Dokument- / PDF-Upload (Private Bucket + Signed URL)
const path = `${user.id}/docs/${crypto.randomUUID()}.pdf`
const { error } = await supabase.storage
.from('documents')
.upload(path, file, {
contentType: 'application/pdf',
upsert: false,
})
if (error) throw error
// Private Bucket: getPublicUrl nicht für Zugriffskontrolle nutzen
const { data, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60 * 60) // 1 Stunde
if (signError) throw signError
console.log('Temporäre Download-URL:', data.signedUrl)
3. Mehrere Bilder parallel hochladen
async function uploadImages(files: File[], userId: string) {
const results = await Promise.allSettled(
files.map(async (file, index) => {
const ext = file.name.split('.').pop()
const path = `${userId}/gallery/${Date.now()}-${index}.${ext}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, { contentType: file.type })
if (error) throw error
return data.path
})
)
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value)
const failed = results.filter(r => r.status === 'rejected')
return { ok, failed }
}
4. Große Dateien mit Resumable Upload (> 6MB)
Standard-.upload() funktioniert bis zu mehreren GB, aber für Zuverlässigkeit ab ~6MB ist die resumable (TUS) API sinnvoll:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, anonKey)
async function uploadLargeFile(file: File, userId: string) {
const path = `${userId}/videos/${file.name}`
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file, {
upsert: false,
// supabase-js nutzt bei größeren Dateien automatisch resumable Uploads,
// wenn die Runtime das unterstützt – sonst tus-js-client
})
if (error) throw error
return data.path
}
Für eine Progress-UI bei sehr großen Dateien lohnt sich tus-js-client gegen den TUS-Endpoint (/storage/v1/upload/resumable) mit dem Access-Token des Users.
Public URLs vs Signed URLs
getPublicUrl() gibt immer einen String zurück – auch bei einem privaten Bucket. Das heißt nicht, dass die Datei erreichbar ist. Public URL nur bei einem öffentlichen Bucket; Signed URL, wenn der Bucket privat ist.
| Use Case | Bucket | API |
|---|---|---|
| Produktbilder, Avatare, alles was auf ein CDN gehört | public = true |
getPublicUrl(path) |
| Rechnungen, Ausweise, Chat-Anhänge, alles User-privat | public = false |
createSignedUrl(path, expiresIn) |
Public URL nach dem Upload
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${user.id}/avatar.jpg`, file, { upsert: true, contentType: file.type })
if (error) throw error
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${user.id}/avatar.jpg`)
// Optional: CDN-Cache nach Überschreiben busten
const avatarUrl = `${publicUrl}?t=${Date.now()}`
Signed URL für eine private Datei
const path = `${user.id}/docs/${file.name}`
await supabase.storage.from('documents').upload(path, file, {
contentType: file.type,
upsert: false,
})
// expiresIn in Sekunden. Der Caller muss SELECT-RLS erfüllen (meist der Owner).
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60 * 60) // 1 Stunde
if (error) throw error
console.log(data.signedUrl)
// Download-Dateiname erzwingen statt Inline-Preview
const { data: dl } = await supabase.storage
.from('documents')
.createSignedUrl(path, 300, { download: file.name })
Mehrere Signed URLs auf einmal
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrls(
['user-id/a.pdf', 'user-id/b.pdf'],
60 * 15
)
if (error) throw error
data.forEach((row) => {
if (row.error) console.error(row.error)
else console.log(row.signedUrl)
})
Liefert createSignedUrl 403, darf die Session das Object nicht SELECTen – dieselbe Ursache wie ein fehlgeschlagener Upload. Storage-Policies fixen, oder den Tabellen-Zwilling unter new row violates row-level security policy anschauen.
Dateien aus Supabase Storage herunterladen
Dateien wieder herauszubekommen ist die Hälfte des Jobs, die die meisten Tutorials überspringen. Es gibt drei Wege – welcher der richtige ist, hängt vom Bucket ab:
| Methode | Bucket | Use Case |
|---|---|---|
getPublicUrl(path) |
public | <img>-Tags, CDN-gecachte Assets |
createSignedUrl(path, expiresIn) |
privat | Temporärer Browser-Zugriff (Preview, E-Mail-Link) |
download(path) |
privat | Datei als Blob in die App holen |
Datei als Blob herunterladen
.download() holt das Objekt über den authentifizierten Client. Der Aufrufer muss die SELECT-Policy auf storage.objects passieren – dieselbe Policy, die schon das RETURNING * des Uploads braucht:
const { data, error } = await supabase.storage
.from('documents')
.download(`${user.id}/report.pdf`)
if (error) {
// Typische Form: { status: 400, message: 'Object not found' }
// – falscher Pfad ODER von RLS blockiert (siehe Problem 9 unten)
console.error(error.message)
throw error
}
// data ist ein Blob
const objectUrl = URL.createObjectURL(data)
// z.B. PDF-Preview: <iframe src={objectUrl} />
"Speichern unter"-Download im Browser auslösen
async function saveFile(path: string, filename: string) {
const { data, error } = await supabase.storage
.from('documents')
.download(path)
if (error) throw error
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
Ohne Blob-Handling geht es auch, wenn eine Signed URL reicht: createSignedUrl(path, 300, { download: 'report.pdf' }) lässt den Browser die Datei speichern statt sie anzuzeigen (siehe Public URLs vs Signed URLs).
Bild verkleinert herunterladen (Transform)
Transformationen funktionieren auch beim Download – praktisch, wenn du das Original gespeichert hast und nur ein Thumbnail brauchst:
const { data, error } = await supabase.storage
.from('uploads')
.download(`${user.id}/photo.jpg`, {
transform: {
width: 800,
quality: 75,
},
})
Server-side Download (API Route / Edge Function)
// Node: Blob in einen Buffer umwandeln für Weiterverarbeitung
const { data, error } = await supabase.storage
.from('documents')
.download(path)
if (error) throw error
const buffer = Buffer.from(await data.arrayBuffer())
// in PDF-Parser pipen, als E-Mail-Anhang, woanders hochladen …
RLS gilt auch für Downloads. .download() und createSignedUrl() brauchen beide SELECT auf storage.objects – ein blockierter Download liefert aber Object not found statt 403, weil Storage nicht verrät, ob eine Datei existiert. Wenn Downloads fehlschlagen: SELECT-Policy aus Schritt 1 prüfen; Hintergrund zur Tabellen-Variante dieses Fehlers: new row violates row-level security policy.
Schritt 3: Advanced Features
Progress Tracking für große Dateien
Für echtes Progress Tracking musst du den Upload chunken:
const uploadFileWithProgress = async (file: File) => {
const chunkSize = 1024 * 1024 // 1MB chunks
const totalChunks = Math.ceil(file.size / chunkSize)
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// Upload chunk...
const progress = ((chunkIndex + 1) / totalChunks) * 100
setUploadProgress(progress)
}
}
Image Resize vor Upload
const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise<File> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
// Aspect Ratio berechnen
const ratio = Math.min(maxWidth / img.width, maxHeight / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
// Image auf Canvas zeichnen
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// Zurück zu File konvertieren
canvas.toBlob((blob) => {
if (blob) {
const resizedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
})
resolve(resizedFile)
}
}, file.type, 0.9)
}
img.src = URL.createObjectURL(file)
})
}
// Verwendung:
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Bild vor Upload resizen
const resizedFile = await resizeImage(file, 1920, 1080)
await uploadFile(resizedFile)
}
Multiple File Upload
const uploadMultipleFiles = async (files: FileList) => {
const uploadPromises = Array.from(files).map(async (file, index) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${index}.${fileExt}`
const filePath = `${user.id}/${fileName}`
return supabase.storage
.from('uploads')
.upload(filePath, file)
})
try {
const results = await Promise.all(uploadPromises)
console.log('All files uploaded:', results)
} catch (error) {
console.error('Some uploads failed:', error)
}
}
Schritt 4: Server-Side Upload (Next.js API Route)
Für sensible Uploads oder Server-Side Verarbeitung:
// pages/api/upload.ts
import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs'
export const config = {
api: {
bodyParser: false, // Disable body parsing for file uploads
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const supabase = createServerSupabaseClient({ req, res })
// Check authentication
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
try {
const form = formidable({
maxFileSize: 10 * 1024 * 1024, // 10MB
keepExtensions: true,
})
const [fields, files] = await form.parse(req)
const file = Array.isArray(files.file) ? files.file[0] : files.file
if (!file) {
return res.status(400).json({ error: 'No file provided' })
}
// Read file
const fileBuffer = fs.readFileSync(file.filepath)
// Generate unique filename
const fileExt = file.originalFilename?.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload to Supabase
const { data, error } = await supabase.storage
.from('uploads')
.upload(filePath, fileBuffer, {
contentType: file.mimetype || 'application/octet-stream',
cacheControl: '3600'
})
if (error) {
throw error
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
// Clean up temp file
fs.unlinkSync(file.filepath)
res.status(200).json({
message: 'Upload successful',
url: publicUrl,
path: data.path
})
} catch (error: any) {
console.error('Upload error:', error)
res.status(500).json({ error: error.message || 'Upload failed' })
}
}
Schritt 5: File Management
Dateien auflisten
const getUserFiles = async () => {
const { data, error } = await supabase.storage
.from('uploads')
.list(user.id, {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
if (error) {
console.error('Error listing files:', error)
return []
}
return data
}
Dateien löschen
const deleteFile = async (filePath: string) => {
const { error } = await supabase.storage
.from('uploads')
.remove([filePath])
if (error) {
console.error('Error deleting file:', error)
return false
}
return true
}
Image Transformationen
Supabase bietet automatische Image-Transformationen:
// Verschiedene Größen generieren
const getImageUrl = (path: string, options?: {
width?: number
height?: number
quality?: number
format?: 'webp' | 'jpeg' | 'png'
}) => {
const { data } = supabase.storage
.from('uploads')
.getPublicUrl(path, {
transform: {
width: options?.width,
height: options?.height,
quality: options?.quality,
format: options?.format
}
})
return data.publicUrl
}
// Usage:
const thumbnailUrl = getImageUrl('user123/image.jpg', {
width: 200,
height: 200,
quality: 80,
format: 'webp'
})
Häufige Upload-Fehler
Die meisten Supabase-Storage-Fehler sind ein kleines Set an Statuscodes. Erst den Code mappen – dann Policy, Limit oder URL-Typ fixen.
| Code | Typische Meldung | Fix |
|---|---|---|
400 |
Invalid JWT / JWT expired | Neu einloggen; nicht ohne Session hochladen |
400 |
Object not found (bei .download()) |
Pfad falsch, oder SELECT-Policy blockiert das Objekt – RLS versteckt seine Existenz |
403 |
new row violates row-level security policy | INSERT + SELECT auf storage.objects; Pfad muss zu auth.uid() passen |
404 |
Bucket not found | Bucket-Name in .from('…') muss zum Dashboard passen |
409 |
The resource already exists | Eindeutiger Pfad oder upsert: true plus UPDATE-Policy |
413 |
The object exceeded the maximum allowed size | file_size_limit erhöhen oder clientseitig komprimieren |
415 |
mime type … is not supported | MIME-Type in allowed_mime_types ergänzen |
function explainStorageError(error) {
const code = String(error.statusCode ?? '')
switch (code) {
case '400':
return 'JWT fehlt oder ist abgelaufen – einloggen und Upload wiederholen.'
case '403':
return 'RLS blockiert INSERT oder das RETURNING-SELECT. INSERT + SELECT auf storage.objects anlegen.'
case '404':
return 'Bucket-Name stimmt nicht mit dem Dashboard überein.'
case '409':
return 'Pfad existiert. Eindeutigen Pfad oder { upsert: true } plus UPDATE-Policy nutzen.'
case '413':
return 'Datei größer als Bucket-/Global-Limit.'
case '415':
return 'MIME-Type steht nicht auf der Allow-List des Buckets.'
default:
return error.message ?? 'Unbekannter Storage-Fehler'
}
}
const { error } = await supabase.storage.from('uploads').upload(path, file)
if (error) throw new Error(explainStorageError(error))
Problem 1: 403 / "new row violates row-level security policy"
Diese Meldung gibt es an zwei Stellen. Nicht verwechseln:
- Tabellen (
supabase.from('projects').insert(…)) – PostgREST SELECTet die neue Zeile mit. Fix: SELECT-Policy oderreturning: 'minimal'. Ausführlich: new row violates row-level security policy. - Storage-Uploads – die Storage-API macht
INSERT … RETURNING *aufstorage.objects. Du brauchst INSERT und SELECT. Walkthrough: Storage 403 RLS beim Upload beheben.
Lösung für Uploads: User muss authentifiziert sein, der erste Ordner muss auth.uid() sein, und beide Policies müssen existieren:
-- Debug: Prüfe ob User authentifiziert ist
SELECT auth.uid(), auth.role();
-- Debug: Prüfe Bucket-Konfiguration
SELECT * FROM storage.buckets WHERE id = 'uploads';
Problem 2: Upload funktioniert, aber Bild wird nicht angezeigt
Lösung: Überprüfe die PUBLIC URL und CORS-Einstellungen:
// Korrekte URL generieren
const { data: { publicUrl }, error } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
if (error) {
console.error('Error getting public URL:', error)
}
Problem 3: Langsame Uploads
Lösungen:
- Dateien vor Upload komprimieren
- Chunked Upload für große Dateien
- CDN aktivieren (automatisch bei Supabase)
- Image-Resize clientseitig
Problem 4: 413 "The object exceeded the maximum allowed size"
Ursache: Die Datei ist größer als das Bucket- oder globale File-Size-Limit in den Storage-Settings.
const { data, error } = await supabase.storage
.from('uploads')
.upload(path, file)
if (error) {
// Typische Form:
// { statusCode: '413', message: 'The object exceeded the maximum allowed size' }
console.error(error.statusCode, error.message)
}
Lösung: Dashboard → Storage → Configuration / Bucket-Settings → Max file size erhöhen. Zusätzlich clientseitig prüfen:
const MAX_MB = 10
if (file.size > MAX_MB * 1024 * 1024) {
throw new Error(`Datei zu groß (max. ${MAX_MB}MB)`)
}
Problem 5: 415 "mime type … is not supported"
Ursache: Der Bucket hat eine allowedMimeTypes-Liste und dein Dateityp steht nicht drauf.
// Expliziter contentType hilft bei ungewöhnlichen Extensions
const { error } = await supabase.storage
.from('uploads')
.upload(path, file, { contentType: file.type || 'application/octet-stream' })
// error.message ≈ 'mime type application/zip is not supported'
Lösung: In den Bucket-Settings die benötigten MIME-Types ergänzen (z.B. image/jpeg, image/png, application/pdf) – oder die Allow-List leeren, wenn du Typen selbst validierst.
Problem 6: 409 / 400 "The resource already exists"
Ursache: Du lädst auf einen Pfad, der schon existiert, und upsert ist false (Default).
// Option A: jedes Mal eindeutiger Pfad
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`
// Option B: überschreiben
await supabase.storage.from('uploads').upload(path, file, { upsert: true })
// Benötigt UPDATE-Policy auf storage.objects
Problem 7: 404 "Bucket not found"
Lösung: Der Bucket-Name in .from('…') muss exakt zum Dashboard passen. Erst Bucket anlegen, dann Frontend deployen.
SELECT id, name, public, file_size_limit, allowed_mime_types
FROM storage.buckets;
Problem 8: 400 "Invalid JWT" / Upload ohne Session
Ursache: .upload() läuft als anon, weil kein Access-Token da ist (abgelaufene Session, SSR-Cookie nicht weitergereicht, oder Client ohne User-Session erzeugt).
const { data: { session } } = await supabase.auth.getSession()
if (!session) {
throw new Error('No session – sign in before uploading')
}
console.log('role', session.user.role, 'uid', session.user.id)
const { error } = await supabase.storage
.from('uploads')
.upload(`${session.user.id}/${file.name}`, file)
Am Server (Next.js Route Handler) den Client aus den Request-Cookies bauen – ein zweites createClient(url, anonKey) ohne User-JWT trifft immer die Anon-Policies.
Problem 9: 400 "Object not found" beim Download
Ursache: Entweder existiert der Pfad wirklich nicht – oder deine SELECT-Policy blockiert das Objekt. Storage antwortet bewusst mit "Object not found" statt 403, um nicht zu verraten, welche Dateien existieren.
const { data, error } = await supabase.storage
.from('documents')
.download('user-id/report.pdf')
// error: { status: 400, message: 'Object not found' }
Lösung: Erst ein Pfad-Problem ausschließen, dann die Policy prüfen:
- Kein führender Slash, und der Bucket-Name gehört nicht in den Pfad:
.from('documents').download('uid/file.pdf'), nicht.download('documents/uid/file.pdf'). - Prüfen, ob das Objekt existiert (der SQL Editor läuft als Service Role und umgeht RLS):
SELECT name FROM storage.objects
WHERE bucket_id = 'documents'
AND name LIKE 'USER-UUID/%';
- Existiert das Objekt, passt die SELECT-Policy aus Schritt 1 nicht – typischerweise weil der erste Ordner nicht die
auth.uid()des Users ist oder der Download ohne Session läuft. Gleiche Root Cause wie der Upload-403: new row violates row-level security policy.
Security Best Practices
1. File Type Validation
const ALLOWED_FILE_TYPES = {
images: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
documents: ['application/pdf', 'application/msword', 'text/plain'],
videos: ['video/mp4', 'video/webm', 'video/ogg']
}
const validateFileType = (file: File, category: keyof typeof ALLOWED_FILE_TYPES) => {
return ALLOWED_FILE_TYPES[category].includes(file.type)
}
2. File Size Limits
const MAX_FILE_SIZES = {
image: 5 * 1024 * 1024, // 5MB
document: 10 * 1024 * 1024, // 10MB
video: 100 * 1024 * 1024 // 100MB
}
3. Malware Scanning
Für Production-Apps solltest du einen Malware-Scanner integrieren:
// Example mit ClamAV
const scanFile = async (fileBuffer: Buffer) => {
// Integration mit Malware-Scanner
// return scanResult
}
Performance Optimierung
1. Lazy Loading für File Listen
const FileList = () => {
const [files, setFiles] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadMoreFiles = async () => {
if (loading || !hasMore) return
setLoading(true)
const newFiles = await getUserFiles(files.length)
if (newFiles.length === 0) {
setHasMore(false)
} else {
setFiles(prev => [...prev, ...newFiles])
}
setLoading(false)
}
return (
<div>
{files.map(file => (
<FileItem key={file.id} file={file} />
))}
{hasMore && (
<button onClick={loadMoreFiles} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
2. Image Caching Strategy
// Service Worker für aggressive Caching
const cacheImages = async (urls: string[]) => {
const cache = await caches.open('supabase-images-v1')
await cache.addAll(urls)
}
FAQ: Häufige Fragen zu Supabase Storage Uploads
Wie lade ich eine Datei mit JavaScript / React in Supabase Storage hoch?
Mit supabase.storage.from('bucket').upload(path, file), nachdem der User eingeloggt ist. Lege Dateien unter {user.id}/… ab, wenn deine RLS-Policies den ersten Ordner gegen auth.uid() prüfen.
Warum kommt beim Supabase-Storage-Upload 403 / "new row violates row-level security policy"?
Fast immer fehlende oder falsche RLS auf storage.objects. Du brauchst INSERT und SELECT (Storage nutzt RETURNING *). Ausführlich: Storage 403 RLS beim Upload beheben. Dieselbe Meldung bei einem Tabellen-Insert ist eine andere Schicht: new row violates row-level security policy.
Wie lade ich eine Datei aus Supabase Storage herunter?
Public Bucket: getPublicUrl(path). Privater Bucket: supabase.storage.from('bucket').download(path) liefert die Datei als Blob, oder createSignedUrl(path, expiresIn) für einen temporären Link. Alle drei brauchen eine SELECT-Policy auf storage.objects. Beispiele: Dateien aus Supabase Storage herunterladen.
Wie bekomme ich nach dem Upload eine Public URL?
Mit getPublicUrl(path) – aber nur, wenn der Bucket öffentlich ist. Bei privaten Buckets createSignedUrl(path, expiresIn) verwenden. Beispiele: Public URLs vs Signed URLs.
Wie lade ich in einen privaten Supabase-Bucket hoch?
public = false lassen, RLS auf den Owner beschränken, mit User-Session uploaden und Zugriff über Signed URLs teilen (nicht über Public URLs).
Wie setze ich das File-Size-Limit in Supabase Storage?
In den Project-Storage-Settings (global) und optional pro Bucket. Ein 413-Fehler heißt: die Datei überschreitet eines dieser Limits.
Wie erlaube ich nur Bilder (MIME-Types) in einem Bucket?
allowedMimeTypes am Bucket setzen (z.B. image/jpeg, image/png, image/webp). Nicht erlaubte Types liefern 415.
Wie überschreibe ich / upsert eine Datei in Supabase Storage?
{ upsert: true } übergeben und eine UPDATE-Policy anlegen, die zu deinen INSERT-Pfadregeln passt.
Wie lade ich mehrere Dateien parallel hoch?
Über die FileList mappen und einzelne .upload()-Calls mit Promise.all / Promise.allSettled ausführen. Besser eindeutige Pfade, damit ein Fehler nicht alles blockiert.
Kann ich ohne Login (anon) hochladen?
Nur wenn du bewusst Policies für die Rolle anon anlegst – Size/Type solltest du trotzdem serverseitig prüfen. Für User-Content: authenticated + Ordner = auth.uid().
Supabase Storage oder direkter S3-Upload?
Supabase Storage, wenn du RLS, Auth und Image Transforms in einem Stack willst. Presigned S3 nur bei speziellem Throughput oder bestehender AWS-Pipeline.
Fazit
Supabase Storage bietet eine mächtige und flexible Lösung für File Uploads. Die wichtigsten Punkte:
- RLS Policies sind kritisch - ohne sie funktioniert nichts
- Bucket Policies (Size-Limit, MIME-Types, Public-Flag) sind getrennt von RLS – 413/415 sind keine Permission-Fehler
- Public vs Signed URLs -
getPublicUrlfür öffentliche Buckets,createSignedUrlfür private Dateien - Downloads brauchen auch SELECT -
.download()liefert einen Blob; ein blockierter Download sagt "Object not found", nicht 403 - Client-side Validierung verbessert UX, aber Server-side ist Pflicht
- Image-Transformationen sparen Bandbreite und verbessern Performance
- Chunked Upload für große Dateien implementieren
- Security-First Approach mit File-Type und Size Validation
Mit diesen Implementierungen hast du eine production-ready File Upload Lösung, die sicher, performant und benutzerfreundlich ist.
Weitere Ressourcen
- RLS Insert-Fehler: new row violates row-level security policy
- Supabase Storage 403 RLS beim Upload
- permission denied for table (42501)
- PGRST116: multiple (or no) rows
- Supabase: infinite recursion detected in policy
- Supabase Auth: Invalid login credentials
- Supabase Edge Functions: Invalid JWT / 401
- Supabase Storage Dokumentation
- Storage RLS Policies Guide
- Image Transformations
Hast du Probleme mit deinem File Upload? Schreib einen Kommentar und ich helfe gerne weiter!
Kommentare