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

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é.
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 CDNupsert: falseévite l’écrasement accidentel ; metteztruesi 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 :
chunkSizeexactement 6 Mo- Token non expiré (24 h)
- 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 :
- 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)
- cacheNonce pour contourner le cache
const { data } = supabase.storage
.from('images')
.getPublicUrl('logo.png', {
cacheNonce: Date.now().toString() // requête unique à chaque fois
})
- 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
| Service | Stockage | Sortie | Gratuit | Points forts |
|---|---|---|---|---|
| Supabase Storage | Tarif S3 | CDN en sus | Inclus Pro | Auth, RLS |
| Cloudflare R2 | 0,015 $/Go | Zéro | 10 Go + 1M ops | Zéro frais de sortie |
| AWS S3 | 0,023 $/Go | 0,09 $/Go | 5 Go/12 mois | Écosystème le plus riche |
| DigitalOcean Spaces | 5 $/250 Go | Inclus | Non | Forfait fixe |
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
- Lifecycle : archiver les anciens fichiers vers Glacier
- Compression : avant upload, ou transformation Supabase
- Public bucket : meilleur cache pour tout ce qui peut être public
- 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
cacheNoncepour 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 :
- Bucket Public ou Private ?
- Policies sur
storage.objects? - 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
- 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
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
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
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
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
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
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 ?
Public bucket ou Private bucket : comment choisir ?
• 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 ?
Supabase Storage ou Cloudflare R2 : le moins cher ?
• 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 ?
```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 ?
9 min de lecture · Publié le: 14 avr. 2026 · Mis à jour le: 27 juil. 2026
Supabase en pratique
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Supabase Realtime en pratique : gestion WebSocket et reconnexion
Guide pratique Supabase Realtime : gestion des connexions WebSocket, stratégie de reconnexion et abonnements Postgres Changes. Choix entre Broadcast, Presence et Postgres Changes, plus bonnes pratiques en production.
Partie 6 sur 10
Suivant
Supabase Edge Functions en pratique : runtime Deno et guide TypeScript
Guide complet Supabase Edge Functions : architecture Deno et isolates V8, commandes CLI, API RESTful avec Hono, du débogage local au déploiement en production
Partie 8 sur 10



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire