Publié le
Sécuriser Next.js, Supabase et Vercel : checklist 2026
Développeur React & Next.js freelance
Sécuriser une app Next.js, Supabase et Vercel commence par la base de données, parce que le navigateur lui parle directement. La clé Supabase est dans le bundle. Seule la Row Level Security (RLS) de Postgres empêche un visiteur de lire les données des autres. Chaque Server Action est un endpoint POST public. Et Next.js sort maintenant un correctif de sécurité par mois, avec deux failles critiques d'exécution de code à distance cette année.
Les agents IA écrivent ces apps plus vite sans les rendre plus sûres. En 2025, un chercheur a trouvé 170 projets générés avec Lovable dont les tables étaient lisibles sans authentification (CVE-2025-48757). La méthode générale pour fiabiliser ce type de code est dans reprendre un projet React généré par IA. Ce guide reprend ce que je vérifie sur les applications que je maintiens, à jour au 6 novembre 2026. La checklist de fin se déroule telle quelle à la main ou par un agent.
Dans cet article :
- Le modèle de menace de la stack
- Supabase : RLS, privilèges, clés, tests
- Next.js : Server Actions, sessions, en-têtes
- Formulaires publics, Vercel et dépendances
- Agents IA : cloisonnement, règles, skill d'audit
- La checklist sécurité complète, avec les commandes
Le modèle de menace d'une app Next.js et Supabase
| Couche | Exposé par conception | Barrière réelle | Erreur fréquente |
|---|---|---|---|
| Supabase (API REST) | URL et clé publishable | RLS et privilèges Postgres | Table sans RLS, using (true) |
| Server Actions | Un endpoint POST par action | Contrôle dans l'action | Contrôle seulement dans le proxy ou l'UI |
| Rendu serveur | Props des Client Components | Colonnes sélectionnées | Objet complet envoyé au navigateur |
| Formulaires | Route POST anonyme | Débit, anti-bot, validation | Limiteur en mémoire |
| Vercel | Variables, previews, crons | Variables sensibles, protection, secret | Secret préfixé NEXT_PUBLIC_ |
| Dépendances | Code tiers à l'installation | Versions figées, délai d'adoption | Scripts d'installation non maîtrisés |
La clé publishable (ex-clé anon) donne les droits du rôle Postgres anon, ou authenticated une fois connecté. Tout ce que ces rôles peuvent faire, un visiteur peut le faire avec curl. La clé secrète contourne toutes les politiques (documentation Supabase).
Sécuriser Supabase : la RLS est la vraie frontière
Activer la RLS automatiquement
Une table créée en SQL n'a pas la RLS par défaut, et une migration générée par un agent l'oublie régulièrement. Un event trigger l'active sur toute nouvelle table de public (documentation) :
create schema if not exists private;
create or replace function private.activer_rls()
returns event_trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
commande record;
begin
for commande in
select * from pg_event_trigger_ddl_commands()
where object_type in ('table', 'partitioned table') and schema_name = 'public'
loop
execute format('alter table if exists %s enable row level security', commande.object_identity);
end loop;
end;
$$;
create event trigger rls_obligatoire
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function private.activer_rls();Une table avec RLS et sans politique refuse tout. Un oubli bloque donc l'écran au lieu d'ouvrir les données.
Révoquer, puis accorder colonne par colonne
Supabase accorde par défaut tous les privilèges à anon et authenticated sur les tables de public. La RLS filtre les lignes et laisse les colonnes ouvertes : un utilisateur qui peut modifier sa ligne peut modifier role ou entreprise_id. Chaque table suit donc le même bloc :
alter table public.intervenants enable row level security;
revoke all on table public.intervenants from anon, authenticated;
grant select, insert (id, initiales, nom, role), update (initiales, nom)
on table public.intervenants to authenticated;La colonne role n'est plus modifiable par l'API, quelle que soit la politique, et une colonne ajoutée plus tard reste fermée tant qu'une migration ne l'ouvre pas.
Même logique pour les fonctions : Postgres accorde execute à public par défaut, et toute fonction du schéma public est appelable via /rest/v1/rpc par un visiteur anonyme.
Écrire des politiques qui isolent
Pour une application multi-tenant, l'appartenance se calcule dans une fonction d'un schéma non exposé, et chaque écriture contrôle la ligne après modification (with check). Sans with check, un utilisateur peut déplacer sa ligne vers l'entreprise d'un autre.
create function private.entreprise_courante()
returns uuid
language sql
stable
security definer
set search_path = ''
as $$
select entreprise_id from public.membres where compte_id = (select auth.uid())
$$;
revoke all on function private.entreprise_courante() from public;
grant usage on schema private to authenticated;
grant execute on function private.entreprise_courante() to authenticated;
create index on public.chantiers (entreprise_id);
create policy "lecture par l'entreprise" on public.chantiers
for select to authenticated
using (entreprise_id = (select private.entreprise_courante()));
create policy "modification par l'entreprise" on public.chantiers
for update to authenticated
using (entreprise_id = (select private.entreprise_courante()))
with check (entreprise_id = (select private.entreprise_courante()));Les détails qui comptent, tous dans le guide RLS de Supabase :
(select auth.uid())est évalué une fois par requête,auth.uid()une fois par ligne.set search_path = ''sur toute fonctionsecurity definerempêche un appelant de rediriger la résolution des noms.- Le schéma
privaten'est pas exposé par l'API : la fonction ne peut pas être appelée directement. - Un index sur la colonne filtrée, car une politique est un
whereajouté à chaque requête. - Les vues ignorent la RLS par défaut.
with (security_invoker = true)la rétablit. Une vuesecurity definervolontaire (pour exposer un sous-ensemble de colonnes) se documente et se protège parrevoke.
using (true) pour authenticated signifie que tout compte connecté voit tout. Sur un tableau de bord interne que j'ai audité, ce choix tient uniquement parce que les inscriptions sont fermées (enable_signup = false). Toute la sécurité tient alors à un réglage du dashboard : le jour où un deuxième compte est créé, il voit tout. La doc de ce projet le dit explicitement et précise la condition qui le rend valable. Sans cette trace, personne ne s'en souviendra le jour où quelqu'un ouvre les inscriptions.
Stockage : bucket privé, taille et types bornés
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('photos', 'photos', false, 4194304, array['image/jpeg', 'image/png', 'image/webp']);
create policy "dépôt dans son propre dossier" on storage.objects
for insert to authenticated
with check (
bucket_id = 'photos'
and (storage.foldername(name))[1] = (select auth.uid())::text
);Clés : publishable et secret
Supabase remplace les clés JWT anon et service_role par sb_publishable_... et sb_secret_..., et annonce la dépréciation des anciennes d'ici fin 2026. Les nouvelles se révoquent une par une. Un tutoriel ou un agent qui demande une longue clé commençant par eyJ date de l'ancien système.
La clé secrète vit dans un seul fichier server-only, utilisé par le strict minimum (tâche planifiée, administration). Si un Client Component l'importe, le build échoue :
import "server-only"
import { createClient } from "@supabase/supabase-js"
export const creerClientAdministration = () =>
createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
})Tester les refus en CI
Une politique sans test reste une supposition. Les tests tournent contre une instance locale (supabase start) avec de vrais comptes, et la CI bloque le déploiement en cas d'échec :
import { expect, it } from "vitest"
import { ENTREPRISE_B, seConnecterEnTantQue } from "./comptes"
it("un membre ne lit pas les chantiers d'une autre entreprise", async () => {
const client = await seConnecterEnTantQue("membre-entreprise-a")
const { data } = await client.from("chantiers").select("id").eq("entreprise_id", ENTREPRISE_B)
expect(data).toEqual([])
})Chaque politique a au moins un test de refus : lecture d'un autre tenant, écriture d'une colonne interdite, déplacement d'une ligne. La démarche est celle de tester les comportements, pas l'UI.
Sécuriser Next.js : chaque Server Action est une route publique
Le contrôle d'accès vit dans l'action
Depuis Next.js 16, middleware.ts s'appelle proxy.ts. Avec Supabase, il rafraîchit la session et redirige les visiteurs non connectés. Il ne suffit pas comme contrôle d'accès : la CVE-2025-29927 permettait de le sauter avec un en-tête en mars 2025, et la release de mai 2026 corrigeait encore quatre contournements classés « High ». Surtout, une Server Action s'appelle par un POST direct, sans interface.
Le contrôle vit dans une couche d'accès aux données côté serveur, comme le recommande la documentation Next.js. Elle vérifie la session et renvoie un client qui porte le jeton de l'utilisateur, pour que la RLS s'applique :
import "server-only"
import { cache } from "react"
import { creerClientServeur } from "@/lib/supabase/serveur"
export const exigerSession = cache(async () => {
const supabase = await creerClientServeur()
const { data } = await supabase.auth.getClaims()
if (!data) throw new Error("Non authentifié")
return { supabase, compteId: data.claims.sub }
})| Méthode | Ce qu'elle vérifie | Usage serveur |
|---|---|---|
getSession() | Rien, lit le cookie | Jamais pour une décision d'accès |
getClaims() | Signature du JWT, en local (JWKS) | Proxy, pages, la plupart des actions |
getUser() | L'utilisateur auprès du serveur Auth | Opérations où une révocation doit être immédiate |
getClaims() est la méthode recommandée par Supabase. Sa limite : un compte suspendu reste valide jusqu'à l'expiration de son jeton.
Session, validation, puis opération
Le type TypeScript d'une action disparaît à l'exécution : son entrée est non fiable.
"use server"
import { z } from "zod"
import { exigerSession } from "@/lib/session"
const RenommerChantier = z.object({
id: z.uuid(),
titre: z.string().trim().min(1).max(120),
})
export const renommerChantier = async (entree: unknown) => {
const { supabase } = await exigerSession()
const resultat = RenommerChantier.safeParse(entree)
if (!resultat.success) return { ok: false, erreur: "Données invalides" } as const
const { data, error } = await supabase
.from("chantiers")
.update({ titre: resultat.data.titre })
.eq("id", resultat.data.id)
.select("id")
if (error || data.length === 0) return { ok: false, erreur: "Chantier introuvable" } as const
return { ok: true } as const
}Le .select("id") final détecte une mise à jour bloquée par la RLS, qui ne renvoie pas d'erreur mais modifie zéro ligne. Un contrôle de rôle applicatif (exigerRole("gerant")) s'ajoute après la session, et la même règle est aussi dans la RLS. Le contrôle applicatif renvoie un message propre à l'utilisateur et la RLS garantit le résultat. Plus de détails sur la validation dans valider une API avec Zod.
Next.js bloque le CSRF sur les Server Actions en comparant Origin et l'hôte. Derrière un reverse proxy, déclarez serverActions.allowedOrigins plutôt que de contourner la vérification.
Ce qui part au navigateur
Les props d'un Client Component sont sérialisées dans la page : un select("*") passé tel quel envoie des colonnes que l'écran n'affiche pas. Les variables NEXT_PUBLIC_ sont inlinées au build et publiques pour toujours : un secret préfixé par erreur se renouvelle, même après correction.
En-têtes de sécurité
Les applications Supabase que j'ai auditées n'avaient que frame-ancestors, sans CSP complète. Une base raisonnable :
import type { NextConfig } from "next"
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.supabase.co",
"connect-src 'self' https://*.supabase.co wss://*.supabase.co",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ")
const nextConfig: NextConfig = {
poweredByHeader: false,
headers: async () => [
{
source: "/(.*)",
headers: [
{ key: "Content-Security-Policy", value: csp },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
],
}
export default nextConfigLimite : 'unsafe-inline' dans script-src laisse passer un script injecté. La version stricte utilise un nonce généré dans proxy.ts, ce qui rend toutes les pages dynamiques : coût faible sur une application authentifiée, réel sur un site statique. En développement, ajoutez 'unsafe-eval' conditionnellement.
Sécuriser les formulaires : débit, bots, injection
Sur les quatre sites que j'ai audités, validation et échappement étaient corrects, mais la limitation de débit reposait sur une Map en mémoire et aucun n'avait d'anti-bot. Sur Vercel, cette Map vit dans une instance : elle se vide au redémarrage et n'est pas partagée entre instances. Ça arrête le spam occasionnel mais pas un robot qui insiste.
Ce que je mets maintenant, dans l'ordre :
- Débit partagé : le Rate Limiting SDK de Vercel, disponible sur Hobby. Hors Vercel : Upstash Ratelimit.
- Anti-bot invisible : Vercel BotID. Hors Vercel : Cloudflare Turnstile.
- Validation bornée : longueur maximale sur chaque champ, énumérations pour les choix.
- Échappement de toute valeur insérée dans un email HTML.
J'ajoute aussi un honeypot puisqu'il ne coûte rien : un champ caché que seuls les robots remplissent.
import { checkRateLimit } from "@vercel/firewall"
import { checkBotId } from "botid/server"
import { z } from "zod"
import { echapperHtml } from "@/lib/echapper-html"
import { resend } from "@/lib/resend"
const Contact = z.object({
nom: z.string().trim().min(1).max(100),
email: z.email().max(150),
message: z.string().trim().min(10).max(5000),
site: z.string().max(200).optional(),
})
export const POST = async (requete: Request) => {
const { rateLimited } = await checkRateLimit("contact", { request: requete })
if (rateLimited) return Response.json({ erreur: "Trop de demandes" }, { status: 429 })
const { isBot } = await checkBotId()
if (isBot) return Response.json({ erreur: "Refusé" }, { status: 403 })
const resultat = Contact.safeParse(await requete.json().catch(() => null))
if (!resultat.success) return Response.json({ erreur: "Données invalides" }, { status: 400 })
const { nom, email, message, site } = resultat.data
if (site) return Response.json({ ok: true })
await resend.emails.send({
from: "Site <contact@exemple.fr>",
to: "contact@exemple.fr",
replyTo: email,
subject: `Nouveau message de ${nom.replace(/[\r\n]+/g, " ")}`,
html: `<p>${echapperHtml(message).replace(/\n/g, "<br>")}</p>`,
})
return Response.json({ ok: true })
}const ENTITES: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}
export const echapperHtml = (valeur: string) => valeur.replace(/[&<>"']/g, (c) => ENTITES[c])Les pièges que le code ne montre pas :
- La règle
contactse crée d'abord dans le pare-feu Vercel (condition@vercel/firewall). Sans elle, l'appel ne limite rien. - BotID s'initialise côté client dans
instrumentation-client.ts:initBotId({ protect: [{ path: "/api/contact", method: "POST" }] }). Une route absente de la liste fait échouercheckBotId(). En local, il renvoie toujoursisBot: false; en production, uncurlest bloqué. - Le honeypot renvoie un succès, pour que le robot passe à autre chose plutôt que d'adapter sa stratégie.
- L'erreur 400 reste générique : renvoyer les erreurs Zod documente le schéma pour un attaquant.
- L'expéditeur est sur un domaine vérifié (SPF, DKIM, DMARC), sinon les emails finissent en spam.
Les fichiers au-delà de 4,5 Mo (limite d'une requête Vercel) partent directement du navigateur vers un bucket privé, via une URL signée.
Vercel : secrets, déploiements, dépendances
Variables, previews, crons
- Les secrets sont marqués Sensitive : illisibles après création.
- Les previews pointent vers un projet Supabase distinct de la production, et la protection des déploiements est active.
- Les crons vérifient
Authorization: Bearer <CRON_SECRET>dans le handler, avec une comparaison à temps constant :
import "server-only"
import { createHash, timingSafeEqual } from "node:crypto"
const empreinte = (valeur: string) => createHash("sha256").update(valeur).digest()
export const estLeSecretDesTaches = (autorisation: string | null, secret: string | undefined) =>
secret !== undefined &&
secret.length >= 32 &&
timingSafeEqual(empreinte(autorisation ?? ""), empreinte(`Bearer ${secret}`))Le hachage donne deux tampons de même longueur, condition de timingSafeEqual.
Déployer après les migrations
Vercel déploie à chaque push par défaut. Si le code part avant la migration qui ajoute une politique, il tourne sans elle. Sur les applications que je maintiens, "git": { "deploymentEnabled": false } dans vercel.json coupe ce déploiement, et un job de CI déploie après les tests RLS et les migrations.
Patcher Next.js
Next.js publie depuis juillet 2026 des releases de sécurité mensuelles, plus des correctifs hors calendrier :
| Date | Versions corrigées | Gravité | Nature |
|---|---|---|---|
| 3 déc. 2025 | 15.5.7, 16.0.7 | Critique (CVSS 10) | React2Shell : exécution de code via les Server Components |
| 7 mai 2026 | 15.5.18, 16.2.6 | Haute | 13 failles, dont 4 contournements du proxy |
| 14 oct. 2026 | 15.5.28, 16.4.1 | Critique | Exécution de code via use cache et l'optimisation d'images SVG |
| 3 nov. 2026 | 15.5.29, 16.4.5 | Haute | Fuites de cache entre tenants, cookie HttpOnly exposé (Edge) |
React2Shell était exploitable sur un create-next-app par défaut et exploitée dès le lendemain. Pour mai 2026, Vercel précise que son pare-feu ne bloque pas ces failles de manière fiable. Être sur Vercel ne dispense donc pas de mettre à jour. Il faut une CI assez fiable pour pousser un correctif le jour même sans y passer l'après-midi.
Dépendances
Le ver Shai-Hulud (2025) s'exécutait à l'installation, volait les jetons de la machine et se republiait dans les paquets de la victime. Les versions piégées étant retirées en quelques heures ou jours, un délai d'adoption évite l'essentiel de ces vagues. pnpm 11 applique un jour par défaut (minimumReleaseAge) ; npm propose min-release-age, en jours, inactif par défaut :
min-release-age=3ignore-scripts=true coupe les scripts d'installation, principal vecteur, mais casse les paquets qui en dépendent : à tester. Côté mises à jour, aucun des projets que j'ai audités n'avait Dependabot ni npm audit en CI :
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
groups:
dependances:
patterns: ["*"]Un correctif de sécurité de Next.js échappe à ce délai et se déploie sans attendre.
Sauvegardes chiffrées
Le plan gratuit de Supabase n'a pas de sauvegarde, et supabase db push ne revient pas en arrière. Une sauvegarde quotidienne en CI, chiffrée avec age et une clé publique versionnée : la clé privée reste hors du dépôt et de la CI, un jeton GitHub volé ne donne que des archives illisibles.
supabase db dump --db-url "$SUPABASE_DB_URL" -f schema.sql
supabase db dump --db-url "$SUPABASE_DB_URL" --data-only -f donnees.sql
test -s donnees.sql || { echo "Sauvegarde vide, abandon"; exit 1; }
tar czf - schema.sql donnees.sql | age -R sauvegarde.age.pub > sauvegarde.tar.gz.ageLa sauvegarde et les migrations partagent un même groupe concurrency dans GitHub Actions, pour ne jamais tourner en même temps.
Agents IA : cloisonner, cadrer, vérifier
Sans consigne, un agent reproduit les raccourcis les plus répandus : getSession() côté serveur, clé service_role pour « résoudre » une erreur RLS, using (true) pour débloquer un écran, action sans vérification de session. L'erreur disparaît de l'écran et la faille reste dans le code.
Cloisonner : ni secrets, ni production
Dans Claude Code, les permissions se versionnent dans le dépôt :
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(npx supabase db push *)",
"Bash(npx supabase * --linked *)"
],
"allow": ["Bash(npx supabase migration up --local)", "Bash(npx supabase test db)"]
}
}Ces règles shell arrêtent une erreur de l'agent. Une variante de la même commande passe quand même. Le sandbox limite les fichiers et les domaines réseau accessibles, et c'est lui qui protège vraiment. Sur un projet audité, l'autorisation npx supabase * laissait l'agent pousser des migrations vers la base liée.
Le serveur MCP de Supabase se limite à un projet de développement, en lecture seule :
{
"mcpServers": {
"supabase": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?project_ref=<ref-du-projet-de-dev>&read_only=true"
}
}
}Sans project_ref, il accède à tous les projets du compte. Supabase désigne l'injection de prompt comme le risque principal : un agent qui lit des données saisies par des utilisateurs peut y trouver des instructions et les exécuter avec ses outils. Sans accès à la prod ni droit d'écriture, même une injection qui passe ne peut pas faire grand-chose.
Cadrer : une section sécurité dans AGENTS.md
## Sécurité
- Toute table de `public` : RLS, `revoke all` pour anon et authenticated,
grants explicites par colonne, politiques `to authenticated`, `with check` sur les écritures.
- Jamais `using (true)` sans validation explicite.
- Fonctions `security definer` : schéma `private`, `set search_path = ''`, `revoke all ... from public`.
- Côté serveur : `getClaims()` ou `getUser()`, jamais `getSession()`.
- Chaque Server Action appelle `exigerSession()` puis valide son entrée avec Zod.
- La clé secrète n'est utilisée que dans `src/lib/supabase/administration.ts`.
Une erreur RLS se corrige dans la politique, jamais avec le client administrateur.
- Chaque politique RLS a un test de refus dans `supabase/tests/`.
- Migrations : `--local` uniquement, jamais `--linked`.La règle sur la clé secrète est celle que j'ajouterais en premier. Face à new row violates row-level security policy, un agent a vite fait de passer sur le client administrateur. L'erreur disparaît avec la protection. D'autres réflexes à refuser en revue sont dans 7 patterns que je refuse en review de code IA.
Vérifier : une skill d'audit et un prompt d'attaque
Les skills officielles de Supabase (npx skills add supabase/agent-skills) apportent les bonnes pratiques Postgres, RLS en tête. Elles ne connaissent pas votre projet : une skill d'audit locale applique la checklist de la section suivante.
---
name: audit-securite
description: Audit de sécurité Next.js, Supabase et Vercel. À utiliser avant une mise en
production, après une migration, ou pour vérifier RLS, Server Actions ou formulaires.
---
Lecture seule : ne modifie aucun fichier.
1. Déroule chaque point de `checklist.md` (même dossier), dans l'ordre.
2. Si le MCP Supabase est disponible, appelle `get_advisors` (type security).
3. Pour chaque point : conforme, non conforme ou non vérifiable, avec fichier, ligne et preuve.
4. Termine par un tableau des non-conformités : gravité (critique, haute, moyenne),
correction proposée. Aucune correction avant validation.Une revue classique vérifie ce qui est écrit. En jouant l'attaquant, l'agent cherche ce qui passe et trouve d'autres failles :
Tu es un attaquant. Liste les requêtes que tu enverrais contre chaque Server Action,
route handler et table exposée de ce projet, en supposant que tu as la clé publishable
et un compte utilisateur normal, et que tu contournes l'interface et le proxy.
Pour chaque requête, indique ce que le code fait réellement, en citant fichier et ligne.En complément, la commande /security-review de Claude Code et sa GitHub Action relisent chaque changement. Elles ignorent vos règles métier. Qu'un technicien ne voie que ses interventions se teste dans supabase/tests/.
Checklist sécurité Next.js, Supabase et Vercel
À copier dans .claude/skills/audit-securite/checklist.md, ou à dérouler à la main. Chaque point se vérifie par une commande, un fichier ou un écran précis.
## Supabase : base
- [ ] Toutes les tables de `public` ont la RLS (Security Advisor : aucun `rls_disabled_in_public`)
- [ ] L'event trigger d'activation automatique de la RLS existe
- [ ] Aucune politique `using (true)` non documentée : `grep -rn "using (true)" supabase/migrations`
- [ ] Chaque politique `update` / `insert` a un `with check`
- [ ] Chaque table : `revoke all ... from anon, authenticated` puis grants explicites
- [ ] Fonctions `security definer` : `set search_path = ''`, schéma non exposé, `revoke ... from public`
- [ ] Aucune fonction de `public` exécutable par `anon` sans raison (`/rest/v1/rpc/...`)
- [ ] Vues : `security_invoker = true`, ou `security definer` documentée et révoquée
- [ ] Buckets : privés, `file_size_limit` et `allowed_mime_types` renseignés, politiques par dossier
- [ ] Realtime : tables publiées soumises à la RLS, canaux broadcast privés
- [ ] Test anonyme : `curl ".../rest/v1/<table>?select=*" -H "apikey: sb_publishable_..."` ne renvoie rien
- [ ] Tests de refus RLS exécutés en CI, bloquants avant déploiement
## Supabase : Auth et clés
- [ ] Clés `sb_publishable_` / `sb_secret_` (plus de `eyJ...` d'ici fin 2026)
- [ ] `grep -rn "SUPABASE_SECRET_KEY\|service_role" src/` : un seul fichier, `server-only`
- [ ] Inscriptions fermées si l'application est interne (`enable_signup = false`)
- [ ] URL de redirection Auth limitées aux domaines de l'application (pas de joker large)
- [ ] Confirmation d'email active, protection contre les mots de passe compromis active (plan Pro)
- [ ] MFA pour les comptes administrateurs du projet Supabase et de l'organisation
## Next.js
- [ ] `next` à la dernière version de sécurité de sa branche (16.4.5 / 15.5.29 au 3 novembre 2026)
- [ ] Aucun `getSession()` côté serveur : `grep -rn "getSession" src/`
- [ ] Chaque fichier `"use server"` et route handler : session, rôle, validation Zod avant l'opération
- [ ] Les erreurs renvoyées au client sont génériques (pas d'erreurs Zod ni de stack)
- [ ] Modules de données et de secrets marqués `import "server-only"`
- [ ] Aucune variable `NEXT_PUBLIC_` contenant un secret : `grep -rn "NEXT_PUBLIC_" src/ .env.example`
- [ ] Aucun `select("*")` passé tel quel à un Client Component
- [ ] `dangerouslySetInnerHTML` absent ou alimenté par du HTML assaini
- [ ] `images.remotePatterns` restreint, `dangerouslyAllowSVG` absent ou à `false`
- [ ] En-têtes servis : `curl -sI https://<domaine> | grep -iE "content-security|strict-transport|nosniff"`
## Formulaires et webhooks
- [ ] Limitation de débit partagée (pare-feu Vercel, Upstash), pas une `Map` en mémoire
- [ ] Détection de bots (BotID, Turnstile) et honeypot
- [ ] Longueur maximale sur chaque champ, énumérations pour les choix
- [ ] Échappement HTML de toute valeur insérée dans un email, sujet sans retour à la ligne
- [ ] `replyTo` alimenté uniquement par un email validé, expéditeur sur un domaine vérifié
- [ ] Webhooks (Stripe, etc.) : signature vérifiée sur le corps brut avant tout traitement
## Vercel et CI
- [ ] Secrets marqués Sensitive, previews sur un projet Supabase distinct
- [ ] Protection des déploiements active sur les previews
- [ ] Crons : `CRON_SECRET` d'au moins 32 caractères, comparaison à temps constant
- [ ] Déploiement après migrations et tests (`deploymentEnabled: false` + job de CI)
- [ ] Dependabot ou Renovate actif, `npm audit --omit=dev` en CI
- [ ] Délai d'adoption des nouvelles versions (`min-release-age`, `minimumReleaseAge`)
- [ ] Détection de secrets avant push (protection GitHub ou gitleaks)
- [ ] Sauvegarde quotidienne chiffrée, qui échoue si elle est vide
## Agents IA
- [ ] `.env` interdit en lecture dans `.claude/settings.json` versionné
- [ ] Commandes Supabase autorisées limitées à `--local`
- [ ] MCP Supabase : `project_ref` d'un projet de dev et `read_only=true`, jamais la production
- [ ] Section sécurité dans `AGENTS.md`
- [ ] `/security-review` ou équivalent sur chaque pull requestLes erreurs à éviter
- Traiter la clé publishable comme un secret. Sa fuite ne devrait rien changer si la RLS fait son travail.
- Contourner une erreur RLS avec la clé secrète. L'erreur dit que la politique est incomplète. C'est elle qu'il faut corriger.
- Protéger dans le proxy ou l'interface seulement. Une action s'appelle directement.
- Écrire une politique sans tester son refus. Une politique trop large ressemble à une politique correcte tant que personne n'essaie.
- Repousser les mises à jour de Next.js. Une version mineure qui fait peur signale une CI trop faible.
FAQ
La clé publishable Supabase peut-elle être exposée côté client ?
Oui. Elle donne les droits des rôles anon et authenticated, filtrés par la RLS. Le risque est une table sans RLS ou une politique trop large. La clé secrète contourne toutes les politiques et ne quitte jamais le serveur.
Faut-il vérifier la session dans chaque Server Action si le proxy redirige déjà ?
Oui. Une Server Action est un endpoint POST appelable sans interface ni proxy, et le proxy a été contourné plusieurs fois. Le proxy sert à rediriger tôt. La protection vient du contrôle dans l'action et de la RLS.
getClaims() suffit-il côté serveur ?
Dans la plupart des cas, oui. Il vérifie la signature du jeton sans appel réseau. Un compte suspendu reste toutefois authentifié jusqu'à l'expiration du jeton : pour une révocation immédiate, utilisez getUser(). Jamais getSession() pour décider d'un accès.
Combien de temps pour sécuriser une application existante ?
Quelques jours pour l'essentiel, sur une application de taille moyenne. Activer la RLS et révoquer les privilèges va vite. Le temps part dans les politiques justes, leurs tests, et la reprise des Server Actions une par une. Le nombre de tables et de rôles, et l'existence d'une CI, font l'essentiel de l'écart.
Comment vérifier qu'une application Supabase est sécurisée ?
Commencez par trois contrôles qui prennent quelques minutes. Appelez l'API REST avec la seule clé publishable sur chaque table : la réponse doit être vide. Ouvrez le Security Advisor du tableau de bord Supabase et traitez chaque erreur. Cherchez getSession et la clé secrète dans src/. La checklist de cet article couvre ensuite le reste, table par table et action par action.
Conclusion
Sur cette stack, aucun backend ne protège tout. La sécurité se joue dans la base, dans chaque action et chaque formulaire, et dans la vitesse des mises à jour. Les agents IA ne changent rien à ces règles. Ils permettent juste de les enfreindre beaucoup plus vite. Les tests et la checklist doivent donc tourner sans qu'on y pense.
Points clés
- La RLS est la seule barrière entre la clé publishable et les données : activée partout, révocation d'abord, refus testés.
- Chaque Server Action vérifie session, rôle et entrée.
- Un formulaire public combine débit partagé, anti-bot, validation bornée et échappement.
- Patcher Next.js vite fait partie de la sécurité, Vercel compris.
- L'agent travaille sans secrets ni production, et déroule la checklist avant chaque mise en production.

Dimitri Dumont
Développeur React & Next.js freelance depuis 8 ans, 33 entreprises accompagnées depuis 2018. J'écris ici ce que je constate en mission : un produit ralentit presque toujours pour les mêmes raisons, une architecture posée dans l'urgence, pas de tests, une mise en production qui fait peur. Mon travail consiste à construire des produits qui n'en arrivent pas là, et à remettre sur pied ceux qui y sont déjà. Mon parcours
La suite
Ce que ça donne sur votre code
Lire un article et lire une vraie codebase sont deux exercices différents. Je lis la vôtre et je vous dis ce qui ralentit, ce que ça coûte de le corriger, et dans quel ordre.