Changer le thème

Supabase Storage en pratique : upload, CDN et contrôle d'accès

Easton editorial illustration: cost-quality-speed triangle

La semaine dernière, un lecteur m’a demandé : « Pour l’upload d’avatar utilisateur, S3 ou Cloudflare R2 ? »

J’ai hésité. J’ai trébuché sur les deux dans des projets différents. S3 m’a fait exploser le cerveau sur les policies IAM ; R2 est moins cher mais il faut monter toute une couche d’authentification. Puis j’ai migré vers Supabase Storage — pas parce que c’est une baguette magique, mais parce que si vous utilisez déjà Auth et la base Supabase, le combo Storage devient vraiment fluide.

Cet article couvre le cœur de Supabase Storage : les trois modes de contrôle d’accès, les pièges des gros uploads, l’optimisation CDN, et la comparaison de coûts avec R2/S3. Le code est prêt à exécuter.


1. Architecture centrale de Supabase Storage

Première chose à savoir : Supabase Storage repose sur AWS S3.

Une fine couche d’abstraction vous permet d’opérer via le SDK JavaScript, sans la complexité des credentials AWS et des policies IAM.

Liaison automatique avec Auth

C’est ce que j’apprécie le plus. Créez un bucket, puis contrôlez qui upload et qui télécharge avec le JWT de supabase.auth.getUser(). Pas besoin d’un système de permissions séparé.

// L'identité utilisateur est transmise automatiquement à l'upload
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

En dessous, Supabase vérifie vos policies RLS (Row Level Security) — configuration détaillée plus loin.

CDN mondial automatique

Les fichiers uploadés passent par Cloudflare CDN. Pas besoin de configurer CloudFront ou Cloudflare Workers vous-même.

Ouvrez les outils développeur et regardez cf-cache-status dans les en-têtes :

cf-cache-status: HIT

HIT = cache servi ; MISS = pas de cache. Les Public bucket ont généralement un meilleur taux que les Private bucket, dont la stratégie de cache est plus stricte.

Smart CDN : invalidation automatique du cache

Point fort de Supabase. Sur un CDN classique, vous purgez manuellement ou attendez le TTL. Smart CDN synchronise les métadonnées vers les edge nodes ; après mise à jour, effet global en 60 s max.

Attention : 60 s restent longs si vous visez du temps réel. Pour un effet immédiat, utilisez cacheNonce (voir plus bas).


2. Comparaison des trois modes de contrôle d’accès

Beaucoup s’y perdent. Public bucket, Private bucket, Signed URL — trois modes, trois cas d’usage. Mauvais choix = taux de cache lamentable ou faille de sécurité.

3
Modes de contrôle d’accès
Public, Private, Signed URL — chacun son usage

2.1 Public Bucket : ressources publiques

Fichiers visibles par tous — logo, illustrations de blog, docs publiques : Public bucket.

Avantages :

  • URL simple : https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • Meilleur taux de cache, CDN répond directement sans Auth
  • Code minimal
// Obtenir une URL publique
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

Cas d’usage :

  • Avatars (affichés publiquement)
  • Illustrations de blog
  • Ressources statiques du site
  • Documents publics

2.2 Private Bucket + Signed URL : fichiers privés

Contrats utilisateur, contenu membre, documents sensibles : Private bucket + Signed URL à durée limitée.

// Lien valide 1 heure
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600) // 3600 s = 1 h

console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx

Attention : chaque Signed URL est unique → impact sur le cache CDN. Si vous en régénérez souvent, le CDN restera en MISS.

Astuce : pour un même fichier consulté plusieurs fois par le même utilisateur, mettez la Signed URL en cache frontend ou Redis.

2.3 Policies RLS : contrôle fin

La partie la plus puissante et la plus négligée. Sur storage.objects, définissez des policies RLS pour contrôler précisément qui fait quoi.

Scénario 1 : upload uniquement dans son dossier

-- Policy sur storage.objects
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 = chemin complet, ex. 'user-123/avatar.jpg'
-- storage.foldername(name)[1] = premier dossier, ex. 'user-123'
-- auth.uid() = ID utilisateur connecté
-- Les deux doivent correspondre

Scénario 2 : admin accède à tout

CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
  auth.jwt() ->> 'role' = 'admin'
);

Scénario 3 : membres uniquement pour le contenu premium

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'
  )
);

Ces trois policies couvrent la plupart des cas métier.


3. Upload de fichiers en pratique

Place à la pratique.

3.1 Upload standard : petits fichiers

Moins de 5 Mo : méthode upload suffit.

// Composant React d'upload
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', // cache navigateur 1 h
        upsert: false // n'écrase pas un fichier existant
      })

    if (error) {
      alert('Échec upload : ' + 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>Upload en cours...</p>}
    </div>
  )
}

Quelques détails :

  • cacheControl = cache navigateur, distinct du cache CDN
  • upsert: false évite l’écrasement accidentel ; mettez true si vous voulez écraser
  • Timestamp ou UUID pour éviter les collisions de noms

3.2 Upload TUS par morceaux : gros fichiers

Au-delà de 5 Mo, ou réseau instable : TUS.

Limite clé : chunkSize doit être 6 Mo, sans exception. Limite codée en dur Supabase.

Validité : URL d’upload valide 24 h, puis à régénérer.

Installez les dépendances :

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus

Code complet :

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, // 100 Mo
      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, // obligatoire 6 Mo
    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>
  )
}

Dépannage : upload bloqué à 6 Mo ? Vérifiez :

  1. chunkSize exactement 6 Mo
  2. Token non expiré (24 h)
  3. Policy RLS autorise INSERT (voir GitHub Issue #563)

3.3 Presigned Upload URL : upload tiers

Pour laisser l’utilisateur uploader sans exposer la service_role key : createSignedUploadUrl.

// Côté serveur : générer l'URL d'upload
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

// data.signedUrl peut être passée au frontend
// Le frontend n'a pas besoin de la service_role key

4. CDN et optimisation d’images

4.1 Smart CDN en détail

Smart CDN invalide le cache à la mise à jour, mais 60 s de propagation peuvent frustrer.

Bonnes pratiques :

  1. Fichiers mis à jour souvent : nouveau chemin
// À éviter : écraser le même fichier
await storage.from('images').upload('logo.png', file, { upsert: true })

// Mieux : nouveau nom à chaque version
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. cacheNonce pour contourner le cache
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // requête unique à chaque fois
  })
  1. Réutiliser les Signed URL

Pour un même utilisateur et un même fichier, stockez la Signed URL au lieu de la régénérer.

4.2 Transformation et optimisation d’images

Supabase transforme les images à la volée — largeur, hauteur, qualité, format.

Limites :

  • Dimensions : 1–2500 px
  • Taille fichier : ≤25 Mo
  • Résolution : ≤50 MP
// Miniature
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover', // ou 'contain', 'fill'
      quality: 80,
      format: 'webp' // conversion WebP automatique
    }
  })

Intégration 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()
}

Facturation : transformations à 5 $/1000 origin images. Si vous générez beaucoup de tailles (images responsives), chiffrez le coût.


5. Comparaison des coûts et choix

Beaucoup s’y intéressent. Voici les options principales.

5.1 Tableau comparatif

ServiceStockageSortieGratuitPoints forts
Supabase StorageTarif S3CDN en susInclus ProAuth, RLS
Cloudflare R20,015 $/GoZéro10 Go + 1M opsZéro frais de sortie
AWS S30,023 $/Go0,09 $/Go5 Go/12 moisÉcosystème le plus riche
DigitalOcean Spaces5 $/250 GoInclusNonForfait fixe
0 $
Frais de sortie Cloudflare R2

5.2 Décision de choix

Gros volumes de téléchargement → R2

Partage d’images, hébergement vidéo : zéro frais de sortie R2 vs ~10 cents/Go S3 — la facture grimpe vite.

Intégration Auth → Supabase Storage

Déjà sur Auth et base Supabase : Storage s’intègre naturellement, policies RLS réutilisables.

Écosystème AWS profond → S3

Lambda, CloudFront, S3 Select, Glacier — si vous êtes ancré AWS, migrer peut coûter plus que l’économie.

Budget fixe, trafic prévisible → DigitalOcean Spaces

Forfait mensuel, idéal pour les petits projets sans surprise de facturation.

5.3 Optimisation des coûts

  1. Lifecycle : archiver les anciens fichiers vers Glacier
  2. Compression : avant upload, ou transformation Supabase
  3. Public bucket : meilleur cache pour tout ce qui peut être public
  4. Réutiliser Signed URL : moins de MISS CDN

6. Dépannage courant

6.1 Ancienne version après mise à jour

Cause : délai de propagation Smart CDN (60 s).

Solutions :

  • Attendre 60 s
  • Uploader vers un nouveau chemin
  • cacheNonce pour contourner le cache

6.2 Upload par morceaux bloqué à 6 Mo

Cause : chunkSize incorrect.

Solution : chunkSize: 6 * 1024 * 1024, exact au byte près.

// Erreur : 5 Mo
chunkSize: 5 * 1024 * 1024 // bloque

// Correct : 6 Mo obligatoire
chunkSize: 6 * 1024 * 1024

6.3 403 Forbidden à l’upload

Cause : policy RLS mal configurée.

Étapes :

  1. Bucket Public ou Private ?
  2. Policies sur storage.objects ?
  3. INSERT autorisé ?
-- Voir les policies existantes
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- Autoriser l'upload
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 Signed URL inaccessible

Cause : URL expirée ou token invalide.

Solutions :

  • Vérifier la durée de validité
  • Token non tronqué
  • Pour tester : URL longue durée (ex. 24 h)

Synthèse

Points clés :

  • Public bucket : ressources publiques, meilleur cache
  • Private bucket + Signed URL : fichiers privés, attention au cache
  • Policies RLS : contrôle fin, ne pas négliger
  • Upload TUS : gros fichiers, chunkSize = 6 Mo
  • Smart CDN : invalidation auto, délai 60 s
  • Choix : gros download → R2 ; Auth → Supabase Storage ; écosystème AWS → S3

Si vous utilisez déjà base et Auth Supabase, Storage est le choix logique. Pour du stockage objet pur, gros trafic et budget serré, R2 sans frais de sortie reste très attractif.

Des questions ou des pièges sur Supabase Storage ? Partagez en commentaire.


Références

Flux complet d'upload Supabase Storage

De la création du bucket à la configuration RLS pour un upload sécurisé et maîtrisé

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Créer un Storage Bucket

    Créez un bucket dans le Supabase Dashboard :

    • Allez dans Storage, cliquez sur Create a new bucket
    • Nommez le bucket (avatars, documents, etc.)
    • Choisissez Public ou Private
    • Public : accès direct ; Private : URL signée requise
  2. 2

    Step 2: Configurer les policies RLS

    Définissez le contrôle d'accès sur la table storage.objects :

    ```sql
    -- L'utilisateur ne gère que ses propres fichiers
    CREATE POLICY "Users manage own files"
    ON storage.objects FOR ALL
    USING (auth.uid()::text = (storage.foldername(name))[1]);
    ```

    • bucket_id correspond au bucket cible
    • auth.uid() récupère l'ID utilisateur courant
    • storage.foldername() analyse le chemin du fichier
  3. 3

    Step 3: Implémenter l'upload standard

    Utilisez upload pour les petits fichiers (<5 Mo) :

    ```typescript
    const { error } = await supabase.storage
    .from('bucket-name')
    .upload('path/file.jpg', file, {
    cacheControl: '3600',
    upsert: false
    });
    ```

    • cacheControl définit la durée de cache navigateur
    • upsert: false empêche d'écraser un fichier existant
  4. 4

    Step 4: Configurer l'upload TUS par morceaux

    Pour les gros fichiers (>5 Mo), utilisez le protocole TUS :

    • Installez : npm install @uppy/tus tus-js-client
    • chunkSize: 6 * 1024 * 1024 (obligatoire 6 Mo)
    • Passez le JWT token dans le header Authorization
    • URL d'upload valide 24 heures
  5. 5

    Step 5: Optimiser le cache CDN

    Astuces pour améliorer le taux de cache :

    • Public bucket : meilleur taux de cache
    • Fichiers mis à jour souvent : nouveau chemin plutôt qu'écrasement
    • cacheNonce pour forcer le rafraîchissement
    • Réutiliser les Signed URL, éviter de les régénérer

FAQ

Pourquoi chunkSize doit-il être exactement 6 Mo sur Supabase Storage ?
C'est une limite codée en dur côté serveur Supabase. Une autre valeur (ex. 5 Mo) bloque l'upload. Configurez précisément : chunkSize: 6 * 1024 * 1024.
Public bucket ou Private bucket : comment choisir ?
Selon les droits d'accès :

• Public bucket : ressources statiques, images publiques, illustrations de blog — accessibles à tous
• Private bucket : fichiers privés, contenu membre, documents sensibles — Signed URL ou policies RLS
Pourquoi voit-on encore l'ancienne version après mise à jour ?
L'invalidation Smart CDN met jusqu'à 60 s pour se propager aux nœuds mondiaux. Trois solutions : attendre 60 s, uploader vers un nouveau chemin (recommandé), ou cacheNonce pour contourner le cache.
Supabase Storage ou Cloudflare R2 : le moins cher ?
Selon votre cas :

• Gros volumes de téléchargement : R2 sans frais de sortie (S3 : 0,09 $/Go)
• Intégration Auth : Supabase Storage plus pratique (policies RLS réutilisables)
• Déjà sur Supabase : Storage est le choix naturel
• Stockage objet pur : R2 moins cher
Comment limiter l'accès aux propres fichiers de l'utilisateur via RLS ?
Utilisez storage.foldername() pour analyser le chemin, avec auth.uid() pour matcher l'ID utilisateur :

```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```

L'utilisateur ne peut opérer que sur les fichiers dont le premier segment de chemin correspond à son ID.
Taux de cache faible avec Signed URL : que faire ?
Chaque Signed URL générée est différente → CDN MISS. Optimisez en mettant en cache la Signed URL côté frontend ou Redis pour réutilisation à court terme ; ou utilisez RLS + Public bucket.

9 min de lecture · Publié le: 14 avr. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog