Supabase Storage in der Praxis: Upload, CDN und Zugriffskontrolle

Letzte Woche fragte mich ein Leser: „Für Avatar-Uploads – S3 oder Cloudflare R2?“
Ich musste kurz nachdenken. In zwei Projekten bin ich genau da hängen geblieben: Bei S3 wurde die IAM-Konfiguration zum Albtraum, bei R2 spart man Geld, baut aber Auth selbst. Später bin ich auf Supabase Storage umgestiegen – nicht weil es ein Allheilmittel ist, sondern weil Auth und Datenbank schon bei Supabase lagen; Storage, Auth und RLS greifen dann wirklich ineinander.
In diesem Artikel geht es um Architektur, drei Zugriffsmodelle, Stolpersteine beim großen Upload, CDN-Optimierung und einen Kostenvergleich mit R2/S3. Der Code ist lauffähig gedacht.
1. Kernarchitektur von Supabase Storage
Kurz vorweg: Supabase Storage nutzt unter der Haube AWS S3.
Darüber liegt eine dünne Schicht – dünn genug, dass Sie per JavaScript-SDK arbeiten können, ohne AWS-Credentials und IAM-Policies selbst zu pflegen.
Automatische Anbindung an Auth
Das ist für mich der größte Pluspunkt: Bucket anlegen, mit dem JWT aus supabase.auth.getUser() steuern, wer hoch- und runterladen darf – ohne separates Berechtigungssystem.
// Beim Upload wird die Nutzeridentität mitgeschickt
const { data, error } = await supabase.storage
.from('avatars')
.upload('user-123/profile.jpg', file)
Im Hintergrund prüft Row Level Security (RLS) – dazu gleich mehr zur Konfiguration.
Globales CDN out of the box
Hochgeladene Dateien laufen über Cloudflare CDN. Kein eigenes CloudFront oder Workers-Setup nötig.
In den DevTools die Response-Header prüfen, z. B. cf-cache-status:
cf-cache-status: HIT
HIT = Cache-Treffer, MISS = kein Treffer. Public Buckets schneiden meist besser ab als Private, weil die Cache-Regeln strenger sind.
Smart CDN: automatische Cache-Invalidierung
Klassische CDNs: nach Datei-Update manuell purgen oder TTL abwarten. Supabase Smart CDN synchronisiert Metadaten an Edge-Knoten – nach einem Update ist die neue Version global in bis zu 60 Sekunden da.
Für echte Echtzeit reicht das oft nicht – dann hilft später cacheNonce.
2. Drei Zugriffsmodelle im Vergleich
Public Bucket, Private Bucket, Signed URL – drei Muster, drei Trade-offs. Falsche Wahl: entweder miserable Cache-Quote oder Sicherheitslücken.
2.1 Public Bucket: Standard für öffentliche Assets
Alles, was ohnehin öffentlich sein soll – Logo, Blog-Bilder, freie Dokumente – gehört in einen Public Bucket.
Vorteile:
- Kurze URL:
https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg - Höchste Cache-Trefferquote, CDN antwortet ohne Auth-Roundtrip
- Minimaler Code
// Public URL abrufen
const { data } = supabase.storage
.from('public-images')
.getPublicUrl('hero-banner.jpg')
console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg
Typische Fälle:
- öffentlich sichtbare Avatare
- Blog-Bilder
- statische Website-Assets
- öffentliche PDFs
2.2 Private Bucket + Signed URL: Standard für geschützte Dateien
Verträge, Member-Inhalte, sensible Dokumente: Private Bucket, Zugriff per zeitlich begrenzter Signed URL.
// Link mit 1 Stunde Gültigkeit
const { data, error } = await supabase.storage
.from('private-docs')
.createSignedUrl('contracts/user-123.pdf', 3600) // 3600 Sekunden = 1 Stunde
console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx
Hinweis: Jede neu erzeugte Signed URL ist anders – die CDN-Quote leidet. Kurzfristig dieselbe Datei? URL im Frontend oder Redis cachen.
2.3 RLS-Policies: feingranulare Kontrolle
Auf storage.objects definieren Sie RLS und steuern präzise, wer was darf.
Szenario 1: Upload nur in den eigenen Ordner
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- name z. B. 'user-123/avatar.jpg'
-- storage.foldername(name)[1] = 'user-123'
-- auth.uid() = ID des eingeloggten Nutzers
Szenario 2: Admins sehen alles
CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
auth.jwt() ->> 'role' = 'admin'
);
Szenario 3: Premium-Download nur für Abonnenten
CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
bucket_id = 'premium-content'
AND EXISTS (
SELECT 1 FROM user_subscriptions
WHERE user_id = auth.uid()
AND status = 'active'
)
);
Damit decken Sie die meisten Produktionsfälle ab.
3. Upload in der Praxis
3.1 Standard-Upload: kleine Dateien
Unter 5 MB reicht upload.
// React-Upload-Komponente
import { useState } from 'react'
import { supabase } from './supabase-client'
export function AvatarUpload() {
const [uploading, setUploading] = useState(false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const filePath = `avatars/${fileName}`
const { error } = await supabase.storage
.from('public-images')
.upload(filePath, file, {
cacheControl: '3600', // Browser-Cache 1 Stunde
upsert: false // keine Überschreibung
})
if (error) {
alert('Upload fehlgeschlagen: ' + error.message)
} else {
const { data } = supabase.storage
.from('public-images')
.getPublicUrl(filePath)
setAvatarUrl(data.publicUrl)
}
setUploading(false)
}
return (
<div>
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
{avatarUrl && <img src={avatarUrl} alt="avatar" />}
{uploading && <p>Wird hochgeladen…</p>}
</div>
)
}
Details:
cacheControl: Browser-Cache, unabhängig vom CDNupsert: false: kein versehentliches Überschreiben (truewenn gewollt)- Zeitstempel oder UUID im Dateinamen gegen Kollisionen
3.2 TUS-Resumable-Upload: große Dateien
Ab 5 MB oder bei instabilem Netz: TUS mit festem Chunk.
Pflicht: chunkSize exakt 6 MB – Server-Vorgabe.
Gültigkeit: Upload-URL 24 Stunden, danach neu anfordern.
Abhängigkeiten:
npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus
Beispiel:
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
export function LargeFileUploader() {
const uppy = new Uppy({
restrictions: {
maxFileSize: 100 * 1024 * 1024, // 100MB
allowedFileTypes: ['video/*', 'image/*']
}
})
const getSession = async () => {
const { data: { session } } = await supabase.auth.getSession()
return session?.access_token || ''
}
uppy.use(Tus, {
endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
chunkSize: 6 * 1024 * 1024, // zwingend 6 MB
async onBeforeRequest(req) {
const token = await getSession()
req.setHeader('Authorization', `Bearer ${token}`)
},
onAfterResponse(req, res) {
const location = res.getHeader('Location')
console.log('File uploaded to:', location)
}
})
return (
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<Dashboard uppy={uppy} />
</div>
)
}
Hängt bei 6 MB? Prüfen:
chunkSizewirklich6 * 1024 * 1024- Token noch gültig (24 h)
- RLS erlaubt INSERT (siehe GitHub Issue #563)
3.3 Presigned Upload URL: Upload durch Dritte
Nutzer sollen direkt hochladen, ohne service_role im Client: createSignedUploadUrl auf dem Server.
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUploadUrl('documents/report.pdf')
// data.signedUrl ans Frontend – kein service_role im Browser
4. CDN und Bildoptimierung
4.1 Smart CDN im Detail
Nach Updates bis zu 60 Sekunden Verzögerung – in manchen UI-Fällen nervig.
Empfehlungen:
- Häufig wechselnde Dateien: neuer Pfad
// schlecht: immer dieselbe Datei überschreiben
await storage.from('images').upload('logo.png', file, { upsert: true })
// besser: Version im Namen
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
cacheNoncefür erzwungenen Bypass
const { data } = supabase.storage
.from('images')
.getPublicUrl('logo.png', {
cacheNonce: Date.now().toString()
})
- Signed URLs wiederverwenden
Nicht bei jedem Klick neu generieren.
4.2 Bildtransformation
Breite, Höhe, Qualität, Format zur Laufzeit anpassbar.
Limits:
- Breite/Höhe: 1–2500 px
- Dateigröße: ≤ 25 MB
- Auflösung: ≤ 50 MP
const { data } = supabase.storage
.from('images')
.getPublicUrl('hero.jpg', {
transform: {
width: 300,
height: 200,
resize: 'cover', // oder 'contain', 'fill'
quality: 80,
format: 'webp'
}
})
Next.js Image Loader:
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/supabase-image-loader.js'
}
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
const url = new URL(src)
url.searchParams.set('width', width.toString())
url.searchParams.set('quality', (quality || 75).toString())
url.searchParams.set('format', 'webp')
return url.toString()
}
Kosten: Bildtransformation ca. $5 pro 1.000 Origin-Bilder – bei vielen responsiven Varianten einplanen.
5. Kosten und Technologiewahl
5.1 Preisvergleich
| Dienst | Speicher | Egress | Freikontingent | Besonderheit |
|---|---|---|---|---|
| Supabase Storage | S3-basiert | CDN extra | in Pro enthalten | Auth, RLS |
| Cloudflare R2 | $0,015/GB | 0 | 10 GB + 1M Ops | keine Egress-Gebühr |
| AWS S3 | $0,023/GB | $0,09/GB | 5 GB/12 Mon. | größtes Ökosystem |
| DigitalOcean Spaces | $5/250GB | inkl. | – | Fixpreis |
5.2 Entscheidungshilfe
Viele Downloads → R2
Bilder-Hosting, Video: R2 spart Egress. S3 kostet schnell spürbar pro GB.
Auth-Integration → Supabase Storage
Nutzen Sie schon Supabase Auth/DB, passen RLS und Storage zusammen.
Tief in AWS → S3
Lambda, CloudFront, Glacier – Wechselkosten können die Ersparnis fressen.
Fixbudget, planbare Last → DigitalOcean Spaces
Monatspauschale für kleinere Projekte.
5.3 Kosten optimieren
- Lifecycle: alte Dateien nach Glacier
- Kompression vor Upload oder Supabase-Transformation
- Public Buckets, wo Sicherheit es erlaubt – bessere Cache-Quote
- Signed URLs cachen – weniger CDN-MISS
6. Troubleshooting
6.1 Nach Update noch alte Datei
Ursache: Smart-CDN-Verzögerung (~60 s).
Lösung: warten, neuer Pfad, oder cacheNonce.
6.2 Upload hängt bei 6 MB
Ursache: falsches chunkSize.
// falsch: 5 MB
chunkSize: 5 * 1024 * 1024
// richtig: 6 MB
chunkSize: 6 * 1024 * 1024
6.3 403 Forbidden
Ursache: RLS.
- Public oder Private?
- Policies auf
storage.objects - INSERT erlaubt?
SELECT * FROM pg_policies WHERE tablename = 'objects';
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');
6.4 Signed URL funktioniert nicht
Ursache: abgelaufen oder Token beschädigt.
- Ablaufzeit prüfen
- Token nicht kürzen
- zum Testen längere Gültigkeit (z. B. 24 h)
Zusammenfassung
- Public Bucket: öffentliche Assets, beste Cache-Quote
- Private + Signed URL: geschützte Dateien, Cache beachten
- RLS: feingranular, nicht unterschätzen
- TUS: große Dateien,
chunkSize= 6 MB - Smart CDN: Auto-Invalidierung, ~60 s Delay
- Wahl: viel Traffic → R2; Supabase-Stack → Storage; AWS-Ökosystem → S3
Wer Supabase-DB und Auth schon nutzt, ergänzt Storage nahtlos. Brauchen Sie nur günstiges Object Storage mit hohem Traffic, ist R2 ohne Egress stark.
Fragen oder eigene Stolpersteine? Gerne in den Kommentaren.
Referenzen
- Storage CDN | Supabase Docs
- Storage Access Control | Supabase Docs
- Resumable Uploads | Supabase Docs
- Image Transformations | Supabase Docs
- Smart CDN | Supabase Docs
- Cloud Storage Pricing | BuildMVPFast
- Supabase Storage v3: Resumable Uploads
- GitHub Issue #563 - TUS Upload Stalling
Supabase Storage: vollständiger Upload-Workflow
Vom Bucket bis zur RLS-Policy – sicherer, kontrollierbarer Datei-Upload
⏱️ Estimated time: 30 min
- 1
Step 1: Storage-Bucket anlegen
Im Supabase Dashboard einen Bucket erstellen:
• Storage öffnen, auf „Create a new bucket“ klicken
• Bucket-Namen vergeben (z. B. avatars, documents)
• Public oder Private wählen
• Public: direkter Zugriff; Private: Signed URL nötig - 2
Step 2: RLS-Policies konfigurieren
Zugriffskontrolle auf der Tabelle storage.objects:
```sql
-- Nutzer dürfen nur eigene Dateien verwalten
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
```
• bucket_id auf den Ziel-Bucket setzen
• auth.uid() liefert die aktuelle User-ID
• storage.foldername() parst den Dateipfad - 3
Step 3: Standard-Upload umsetzen
Kleine Dateien (<5 MB) mit upload:
```typescript
const { error } = await supabase.storage
.from('bucket-name')
.upload('path/file.jpg', file, {
cacheControl: '3600',
upsert: false
});
```
• cacheControl: Browser-Cache-Dauer
• upsert: false verhindert Überschreiben - 4
Step 4: TUS-Resumable-Upload einrichten
Große Dateien (>5 MB) per TUS:
• Abhängigkeiten: npm install @uppy/tus tus-js-client
• chunkSize: 6 * 1024 * 1024 (zwingend 6 MB)
• Authorization-Header mit JWT
• Upload-URL 24 Stunden gültig - 5
Step 5: CDN-Cache optimieren
Höhere Cache-Trefferquote:
• Public Buckets performen am besten
• Häufige Updates: neuen Pfad statt Überschreiben
• cacheNonce zum erzwungenen Refresh
• Signed URLs wiederverwenden, nicht ständig neu erzeugen
FAQ
Warum muss chunkSize bei Supabase Storage genau 6 MB sein?
Public oder Private Bucket – wann was?
• Public: statische Assets, öffentliche Bilder, Blog-Bilder – für alle erreichbar
• Private: Nutzerdateien, Member-Content, sensible Dokumente – Zugriff nur per Signed URL oder RLS
Warum sehe ich nach einem Update noch die alte Datei?
Supabase Storage oder Cloudflare R2 – was ist günstiger?
• Hohe Downloads: R2 ohne Egress-Gebühren (S3 ca. $0,09/GB)
• Auth-Integration: Supabase Storage (RLS direkt nutzbar)
• Bereits auf Supabase: Storage ist der nahtlose Weg
• Reines Object Storage: R2 oft günstiger
Wie beschränke ich RLS so, dass Nutzer nur eigene Dateien sehen?
```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```
So sind nur Dateien erlaubt, deren erster Pfadsegment der User-ID entspricht.
Niedrige Cache-Trefferquote bei Signed URLs – was tun?
7 Min. Lesezeit · Veröffentlicht am: 14. Apr. 2026 · Aktualisiert am: 14. Juli 2026
Supabase in der Praxis
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Supabase Realtime in der Praxis: WebSocket-Verbindungsmanagement und Reconnect-Strategien
Praxisleitfaden zu Supabase Realtime: WebSocket-Verbindungsmanagement, Reconnect-Strategien und Echtzeit-Abonnements für Postgres Changes. Broadcast, Presence und Postgres Changes – Auswahl und Best Practices für Produktion.
Teil 6 von 10
Nächster
Supabase Edge Functions in der Praxis: Deno-Runtime und TypeScript-Entwicklungsleitfaden
Supabase Edge Functions vertieft: Deno-Runtime-Architektur und V8-Isolate-Prinzip, CLI-Workflow, RESTful API mit Hono – von lokalem Debugging bis Produktions-Deployment
Teil 8 von 10
Ähnliche Beiträge
Supabase Einstieg: PostgreSQL + Auth + Storage als All-in-One-Backend

Supabase Einstieg: PostgreSQL + Auth + Storage als All-in-One-Backend
Supabase-Datenbankdesign: Tabellenstruktur, Beziehungen und Row Level Security – vollständiger Leitfaden


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen