DeviensDevInstagram

Sécurité Supabase · version 1doc vérifiée le 17 octobre 2026par Vincent le Serpent

La checklist de sécurité Supabase

Supabase est génial pour sortir vite une première app. Mais la même facilité te laisse livrer une base ouverte sans t'en rendre compte : mon premier SaaS s'est fait hacker comme ça. Voici tout ce que je vérifie avant de mettre un site en prod.

1

La RLS, activée partout

C'est la seule barrière entre un inconnu et tes données. Sans elle, ta table est ouverte.

Supabase te donne une API automatique sur ta base (une Postgres, gratuite pour démarrer) : dès que tu crées une table, elle est requêtable depuis Internet avec la clé publique du projet. Ce qui décide qui lit quoi, ce n'est pas la clé : c'est la Row Level Security (RLS), des règles posées sur chaque table. Une table sans RLS, dans un schéma exposé, est lisible et modifiable par n'importe quel visiteur.

  • Active la RLS sur chaque table du schéma public : alter table public.ma_table enable row level security;
  • Passe le Security Advisor du dashboard (ou supabase db advisors) et règle chaque ERREUR. Il liste les tables « unrestricted », les policies posées sur une table dont la RLS est désactivée (elles ne s'appliquent pas), et les colonnes sensibles exposées.
  • RLS activée mais aucune policy = tout est refusé. C'est le bon défaut (« deny by default ») : tu ouvres ensuite, ligne par ligne, avec des policies.
  • Une policy par opération (select, insert, update, delete), filtrée par (select auth.uid()) ; jamais de using (true) sur des données privées.
  • Attention aux vues : par défaut une vue ignore la RLS des tables qu'elle lit. Sur Postgres 15+, crée-la en security_invoker = true, sinon ne l'expose pas.

SourceSupabase — Row Level Security · Database Advisors · Going into prod

2

Le piège de la colonne is_admin

La RLS dit quelles lignes on peut modifier, pas quelles colonnes. C'est là que la plupart des SaaS se font avoir.

Voici le trou le plus courant, et le plus discret. Tu écris une policy saine : « un utilisateur peut modifier sa ligne de profil ».

create policy "je modifie mon profil"
  on public.profiles for update
  using  ( (select auth.uid()) = id )
  with check ( (select auth.uid()) = id );

Le problème : cette règle dit quelle ligne, pas quelles colonnes. Depuis la console du navigateur, avec la clé publique, l'utilisateur envoie :

supabase.from('profiles')
  .update({ is_admin: true })
  .eq('id', monPropreId)

La policy est satisfaite — c'est bien sa ligne — et il devient admin. Cacher le champ dans ton interface ne change rien : l'API répond directement. Quatre façons de fermer le trou :

ApprocheCe que tu fais
Table à part (recommandé par la doc)Sors role dans une table user_roles que le client peut lire mais jamais écrire ; seul le serveur y touche.
Droits par colonnerevoke update sur la table, puis grant update (display_name, avatar_url) aux seules colonnes permises.
Vérrou dans le with checkImpose que la nouvelle valeur sensible soit égale à l'ancienne. Ça marche, mais c'est fragile.
Trigger before updateRefuse la requête si is_admin change et que l'appelant n'est pas le serveur.

SourceSupabase — Column Level Security · Database linter (lint 0015, references_user_metadata)

3

Les deux clés : publique, et secrète

L'une est faite pour être dans ton site web. L'autre, si elle fuite, ouvre toute ta base.

Supabase te donne deux clés (renommées récemment, les anciens noms cohabitent encore) :

CléOù elle va
publishable (ex-anon)Publique par conception. Elle part dans ton bundle, c'est normal : elle passe par la RLS, donc c'est la RLS qui protège. Elle identifie le projet, ce n'est pas un secret.
secret (ex-service_role)Contourne toute la RLS. Uniquement côté serveur (Edge Function, API, job). Jamais dans le front, jamais dans git, jamais dans une variable préfixée NEXT_PUBLIC_.
  • Cherche la clé secrète là où elle n'a rien à faire : dans le bundle (grep sb_secret_ sur ton build), dans les .env commités, et dans l'historique git (git log -p -S sb_secret_).
  • Si elle a fuité une seule fois, considère-la compromise : corrige la cause, puis fais tourner la clé (crée la neuve, déploie, supprime l'ancienne). La retirer du code ne suffit pas : les anciens builds et les caches la gardent.
  • Ne compte pas sur le blocage navigateur. Supabase refuse une clé secrète envoyée depuis un navigateur (erreur 401), mais c'est un garde-fou : une clé fuitée reste utilisable depuis n'importe quel autre outil.

SourceSupabase — API keys · Securing your data

4

Le Storage : tes fichiers privés le restent

C'est le point qui fait fuiter les formations payantes : la vidéo s'affiche, donc le navigateur a reçu son URL.

Si tu vends du contenu — des vidéos, des PDF — le piège est simple : dès que le navigateur affiche le fichier, il en a reçu l'URL. Les outils de développement, l'onglet réseau ou le code source la récupèrent. Un « paywall » qui ne fait que masquer le lecteur ne protège rien.

  • Les fichiers payants vont dans un bucket privé. Les buckets sont privés par défaut ; dans un bucket public, la lecture est ouverte à quiconque a l'URL.
  • On les sert par URL signée (createSignedUrl), générée côté serveur, après avoir vérifié que la personne a payé, et qui expire vite (quelques minutes).
  • Ne stocke jamais l'URL publique du fichier en base : une table qui liste tes vidéos avec leur lien, lisible par la clé publique, fuite tout le catalogue d'un coup.
  • Pose des policies RLS sur storage.objects, en liant le chemin du fichier au droit de la personne (pas un simple « connecté »), et vérifie qu'un bucket public n'a pas de policy qui laisse lister son contenu.

SourceSupabase — Storage buckets · Storage access control · Signed URLs

5

Les fonctions SECURITY DEFINER

Une fonction mal réglée rend ouvert ce que ta RLS protège. Elle est appelable depuis l'API.

Une fonction Postgres en SECURITY DEFINER s'exécute avec les droits de son créateur — souvent un rôle qui contourne la RLS. Comme toute fonction est, par défaut, exécutable par n'importe quel rôle (anon compris) via /rest/v1/rpc, elle peut renvoyer des lignes que l'appelant ne devrait pas voir.

  • Préfère SECURITY INVOKER (le défaut) ; chaque DEFINER doit être justifié.
  • Toute fonction DEFINER fixe set search_path = '' et qualifie les noms (public.ma_table) — sinon, détournement possible (lint 0011).
  • Révoque EXECUTE sur les fonctions qui n'ont pas à être publiques : revoke execute on function … from public, anon;
  • Une fonction qui renvoie des données par utilisateur revérifie la propriété dans son corps (where user_id = (select auth.uid())) : le droit d'exécuter ne limite pas les lignes rendues.

SourceSupabase — Database functions · Database linter

6

L'authentification, réglée pour la prod

Les réglages par défaut sont faits pour le développement. Avant de livrer, tu repasses dessus.

  • Confirmation d'email activée en production (sur un projet auto-hébergé, elle est désactivée par défaut).
  • Site URL = ton URL de production, et une liste blanche de Redirect URLs en chemins exacts — pas de joker ** large, réservé aux previews.
  • Mot de passe : longueur minimale raisonnable, et protection contre les mots de passe déjà fuités (HaveIBeenPwned, à partir du plan Pro).
  • Limites de débit et CAPTCHA sur inscription, connexion et réinitialisation ; SMTP sur ton propre domaine.
  • Ne publie pas les emails : le schéma auth n'est pas exposé à l'API ; passe par une table profiles sous RLS, et n'y mets que du public.
  • Le JWT est signé mais lisible : n'y mets jamais de secret. Côté serveur, vérifie le jeton auprès de Supabase, ne te fie pas à un décodage local.
  • Connexions anonymes activées ? Un utilisateur anonyme porte le rôle authenticated : relis toutes tes policies to authenticated.

SourceSupabase — Going into prod · Redirect URLs · JWTs

7

Côté Next.js : rien de secret ne part au navigateur

La moitié des fuites ne viennent pas de Supabase, mais de ce que ton front compile et envoie.

Supabase se branche presque toujours à une appli — souvent Next.js, hébergée sur Vercel dans ma stack. Deux pièges s'ajoutent à la base de données.

  • Tout ce qui est préfixé NEXT_PUBLIC_ est public : Next.js l'écrit en dur dans le JavaScript du navigateur au build. NEXT_PUBLIC_SUPABASE_URL et la clé publishable, d'accord ; une clé secret préfixée ainsi, catastrophe. (Même piège avec le bloc env: {} de next.config.js.)
  • Une page compilée en statique est publique, données comprises. Ce qu'un composant serveur lit au build est figé dans le HTML et servi à tous ; avec output: 'export', il n'y a aucun serveur, donc rien n'est protégeable par ce biais. Ne lis jamais de donnée privée dans une page destinée à être statique.
  • Le contrôle d'accès se fait côté serveur, au plus près de la donnée. Une route d'API, une Server Action : chacune revérifie qui appelle et si la ressource lui appartient (sinon, on change l'id dans l'URL et on lit la ressource d'un autre). Cacher un bouton ne protège pas la donnée derrière.
  • Les source maps de prod sont désactivées (défaut), /public ne contient aucun fichier réservé, et Next.js est à jour (la faille de contournement du middleware, CVE-2025-29927, concernait les apps auto-hébergées).

SourceNext.js — Environment variables · Data security · Authentication

8

Teste ton propre site : demande à Claude de l'attaquer

La meilleure façon de trouver un trou avant un inconnu, c'est de chercher comme lui — sur ta propre app.

Une IA de code est très douée pour te dire que c'est sécurisé, même quand ça ne l'est pas. La parade : lui demander l'inverse, de chercher ce qui est exposé, sur ta propre application. C'est un auto-audit, la même idée que de relire son code avant de livrer. Colle ces prompts dans ton assistant, avec ton dépôt ouvert.

D'abord le cadre : on ne teste que sa propre app, ou une app pour laquelle on a une autorisation écrite. Jamais le site de quelqu'un d'autre. Tout ce qui écrit se fait sur un environnement de test, avec des comptes que tu as créés.

1. Lister ce qui n'a pas de RLS

Contexte : je suis le propriétaire et le seul développeur de cette app (Next.js + Supabase), j'audite MON PROPRE projet avant la prod. Tu as mon dépôt et mes migrations SQL.

En lecture seule :
1. Liste chaque table du schéma public avec : RLS activée oui/non, nombre de policies.
2. Pour chaque table SANS RLS, dis ce qu'un porteur de la clé publique peut lire et écrire.
3. Pour chaque policy, dis ce qu'un utilisateur lambda pourrait lire ou modifier qu'il ne devrait pas (cherche : using (true), absence de with check, policies basées sur user_metadata, vues sans security_invoker, fonctions SECURITY DEFINER exposées).
Rends un tableau : table | problème | gravité | correctif SQL. Ne modifie rien, et ne marque "OK" que ce que tu as vérifié.

2. Ce que donne ta clé publique, vue de l'extérieur

Contexte : ceci est MON projet Supabase, j'en suis propriétaire et j'autorise ce test.
URL du projet : <la tienne> — clé publique (déjà dans mon bundle) : <la tienne>.

Mets-toi à la place d'un visiteur anonyme qui n'a que cette clé. En LECTURE seule, limit=3, jamais d'écriture :
1. Quelles tables et RPC l'API expose-t-elle ?
2. Pour chacune, un GET renvoie-t-il des lignes ?
3. Signale toute donnée sensible visible (emails, tokens, clés, URLs de fichiers privés) — masque les valeurs dans ton rapport.
4. Quels buckets Storage sont publics et listables ?
Une réponse saine = "0 ligne" ou "permission denied" partout, sauf données volontairement publiques.

3. Tenter l'escalade de privilèges (sur un compte de test)

Contexte : MON app, environnement de test, avec un compte user_a que j'ai créé. J'autorise ce test sur mon propre projet.

1. Lis le schéma de ma table profils (role, is_admin, plan…) et ses policies update/insert.
2. Avec le jeton de user_a, tente via l'API de passer is_admin / role / plan à une valeur élevée sur sa propre ligne, puis sur la ligne d'un autre.
3. Dis-moi, par champ : protégé ou modifiable, la cause dans la policy, le correctif.
Remets chaque valeur d'origine après l'essai. Rien sur la prod ni sur des comptes réels. Un champ "modifiable" = échec bloquant.

4. Chercher un secret ou une URL privée dans le bundle

Contexte : MON app Next.js, que je m'apprête à déployer.

1. Fais un build local, puis cherche dans .next/static et le HTML : clé service_role / secret, JWT, clés d'API tierces (sk_…), chaînes de connexion Postgres.
2. Liste les variables NEXT_PUBLIC_* et dis lesquelles ne devraient pas être publiques.
3. Cherche des URLs de contenu censé être privé (liens directs vers un bucket, une vidéo, un PDF ; URLs signées sans expiration ; fichiers payants dans /public).
Masque les secrets trouvés (4 premiers caractères). Dis-moi lesquels je dois considérer comme compromis et faire tourner.

5. Vérifier que le contenu payant est vraiment fermé

Contexte : MON site vend des vidéos. J'autorise ce test sur mon propre site, avec un compte gratuit que j'ai créé.

1. Lis le code du lecteur et de l'API qui sert les médias : l'accès est-il décidé côté serveur, ou seulement dans l'interface ?
2. Récupère l'URL d'une vidéo payante (réseau ou code), puis demande-la sans compte et avec un compte gratuit. Réponse attendue : 401/403.
3. Si les URLs sont signées : quelle expiration ? Une URL expirée est-elle réutilisable ?
4. Le bucket est-il public ? Les noms sont-ils devinables ? Peut-on lister le dossier ?
Preuve minimale (le code HTTP suffit), ne télécharge pas les fichiers.

Et un dernier, en relecteur sceptique : « Voici les rapports précédents de mon app. Dédoublonne, classe par gravité, et pour chaque point critique dis-moi comment je le reproduis moi-même. Signale surtout ce que tu n'as PAS pu vérifier, plutôt que de conclure que tout est bon. » La liste des angles morts compte autant que les failles trouvées.

SourceAnthropic — Automate security reviews with Claude Code · OWASP — Authorization Cheat Sheet