Design wechseln

Supabase Storage in der Praxis: Upload, CDN und Zugriffskontrolle

Easton editorial illustration: cost-quality-speed triangle

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.

3
Zugriffsmodelle
Public, Private, Signed URL – je nach Szenario

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 CDN
  • upsert: false: kein versehentliches Überschreiben (true wenn 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:

  1. chunkSize wirklich 6 * 1024 * 1024
  2. Token noch gültig (24 h)
  3. 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:

  1. 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)
  1. cacheNonce für erzwungenen Bypass
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString()
  })
  1. 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

DienstSpeicherEgressFreikontingentBesonderheit
Supabase StorageS3-basiertCDN extrain Pro enthaltenAuth, RLS
Cloudflare R2$0,015/GB010 GB + 1M Opskeine Egress-Gebühr
AWS S3$0,023/GB$0,09/GB5 GB/12 Mon.größtes Ökosystem
DigitalOcean Spaces$5/250GBinkl.Fixpreis
$0
Egress bei Cloudflare R2

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

  1. Lifecycle: alte Dateien nach Glacier
  2. Kompression vor Upload oder Supabase-Transformation
  3. Public Buckets, wo Sicherheit es erlaubt – bessere Cache-Quote
  4. 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.

  1. Public oder Private?
  2. Policies auf storage.objects
  3. 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

Supabase Storage: vollständiger Upload-Workflow

Vom Bucket bis zur RLS-Policy – sicherer, kontrollierbarer Datei-Upload

⏱️ Estimated time: 30 min

  1. 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. 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. 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. 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. 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?
Das ist eine feste Server-Begrenzung. Andere Werte (z. B. 5 MB) lassen den Upload hängen. Im Code exakt setzen: chunkSize: 6 * 1024 * 1024.
Public oder Private Bucket – wann was?
Nach Zugriffsanforderung wählen:

• 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?
Smart CDN braucht bis zu 60 Sekunden, bis die Invalidierung global wirkt. Drei Wege: 60 Sekunden warten, neuen Pfad hochladen (empfohlen) oder cacheNonce zum Cache-Bypass.
Supabase Storage oder Cloudflare R2 – was ist günstiger?
Abhängig vom Szenario:

• 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?
storage.foldername() parst den Pfad, auth.uid() die User-ID:

```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?
Jede neu generierte Signed URL ist anders → CDN MISS. Lösung: Signed URL im Frontend oder Redis cachen und kurz wiederverwenden; oder RLS statt Signed URL und Public Bucket, wo möglich.

7 Min. Lesezeit · Veröffentlicht am: 14. Apr. 2026 · Aktualisiert am: 14. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog