Chapitre 10.1 — Frontière client/serveur & secrets
⏱️ TL;DR — En Next.js (App Router), du code s’exécute côté serveur (Server Components par défaut, Server Actions, route handlers) et du code côté client (composants marqués
'use client'). La faille propre au stack : envoyer un secret côté client sans le vouloir. Toute variable d’environnement préfixéeNEXT_PUBLIC_est inlined dans le bundle du navigateur — donc publique. Et tout ce qui est importé/utilisé dans un Client Component part au navigateur. Un secret (clé d’API, token) doit rester exclusivement côté serveur : pas deNEXT_PUBLIC_, pas d’usage dans un composant client, pas dans les props sérialisées vers le client. La règle : le client est public — n’y mets jamais ce qui doit rester secret.
🎯 Objectifs
- Comprendre où s’exécute le code en Next.js (Server vs Client Components,
'use client'). - Savoir que
NEXT_PUBLIC_expose une variable au bundle navigateur (donc publique). - Empêcher les fuites de secrets vers le client (env, props, imports).
- Reconnaître ce qui traverse la frontière et ce qui reste serveur.
Où s’exécute mon code ?
Dans l’App Router de Next.js, les composants sont Server Components par défaut : ils s’exécutent sur le serveur, peuvent accéder à la base, aux secrets, au système de fichiers, et n’envoient au navigateur que du HTML/données. Un composant devient Client Component quand on met la directive 'use client' en tête du fichier : son code est alors envoyé au navigateur et s’y exécute (interactivité, hooks, événements).
// Server Component (par defaut) : s'execute sur le SERVEUR
// -> peut lire des secrets, la base... rien de son code ne part au client
async function Page() {
const data = await db.query(...) // OK cote serveur
return <Dashboard data={data} />
}'use client' // <- ce fichier et son code partent au NAVIGATEUR
export function Compteur() {
const [n, setN] = useState(0) // interactivite cote client
return <button onClick={() => setN(n + 1)}>{n}</button>
}La conséquence de sécurité : tout ce qui vit dans un Client Component (ou est importé par lui) part au navigateur — code, constantes, et toute valeur qu’il utilise. Un secret qui atterrit, même indirectement, dans du code client est exposé.
Le piège NEXT_PUBLIC_
Next.js distingue deux types de variables d’environnement :
- Sans préfixe (
DATABASE_URL,STRIPE_SECRET_KEY) : disponibles uniquement côté serveur. Elles ne sont pas envoyées au navigateur. C’est là que vont les secrets. - Préfixées
NEXT_PUBLIC_(NEXT_PUBLIC_API_URL) : inlinées dans le bundle JavaScript au build → visibles par tout le monde dans le navigateur. À réserver aux valeurs non secrètes (URL publique d’API, clé publique d’un service, identifiant d’analytics).
# .env
DATABASE_URL=postgres://... # SERVEUR uniquement (secret)
STRIPE_SECRET_KEY=sk_live_... # SERVEUR uniquement (secret !)
NEXT_PUBLIC_APP_URL=https://app.form # PUBLIC (inline dans le bundle client)// ❌ CATASTROPHE : une cle secrete prefixee NEXT_PUBLIC_ -> exposee a tous
// NEXT_PUBLIC_STRIPE_SECRET=sk_live_... // n'importe qui la lit dans le bundle !
// ✅ La cle secrete reste sans prefixe et n'est lue que cote serveur
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // dans un Server Component / route handler⚠️ Piège — Ajouter
NEXT_PUBLIC_à une variable pour « qu’elle soit accessible dans le composant » est le moyen le plus courant de fuiter une clé secrète. Le préfixe publie la valeur dans le bundle navigateur — c’est le contraire d’un secret. Toute clé secrète (API tierce, base, signature) doit rester sans préfixe et n’être utilisée que côté serveur (Server Component, Server Action, route handler). Si tu es tenté de préfixer un secret pour « faire marcher » un composant client, c’est le signe qu’il faut passer par le serveur (une route/Server Action), pas exposer la clé.
Les autres fuites vers le client
Le préfixe n’est pas le seul vecteur. Un secret peut fuiter par :
- Les props sérialisées : un Server Component qui passe une valeur secrète en prop à un Client Component → cette prop est sérialisée et envoyée au navigateur. Ne passe au client que ce qui peut être public.
- Un import partagé : un module qui contient un secret, importé par un Client Component, embarque le secret dans le bundle. Isole le code serveur (accès secrets/DB) dans des modules qui ne sont jamais importés côté client — Next.js propose
import 'server-only'pour faire échouer le build si un module serveur est importé côté client. - Les réponses d’API sur-exposées : un route handler qui renvoie un objet utilisateur complet (avec
passwordHash, tokens, champs internes) au client. Ne renvoie que les champs nécessaires (DTO/sélection explicite).
// ✅ 'server-only' : casse le build si ce module est importe cote client
import 'server-only'
export const apiKey = process.env.SECRET_API_KEY // protege : jamais bundle cote client💡 Réflexe — Pose-toi, pour toute valeur : « est-ce que ça peut apparaître dans le navigateur ? ». Si c’est un secret, la réponse doit être non, ce qui implique : pas de
NEXT_PUBLIC_, pas d’usage dans un Client Component, pas de passage en prop au client, pas dans une réponse d’API. Utiliseimport 'server-only'pour garantir qu’un module de secrets ne fuit pas. Et vérifie ton bundle : ce qui est dedans est public, par définition.
Vérifier ce qui part au client
Concrètement, on peut auditer le bundle : chercher dans les fichiers JS servis au navigateur toute trace de secret (une recherche du préfixe d’une clé, d’un token). Des outils d’analyse de bundle aident. En revue de code, on repère : les NEXT_PUBLIC_ sur des valeurs sensibles, les 'use client' qui importent du code serveur, les props qui transportent des données internes. La règle mentale reste simple : la frontière est réelle, même si le code se ressemble — un fichier « qui a l’air pareil » peut être serveur ou client, et ça change tout pour un secret.
🎯 Côté attaquant — La première chose qu’un attaquant fait sur une app Next.js/React : ouvrir les DevTools et lire le bundle JavaScript (et les réponses réseau). Il y cherche des clés (
sk_, tokens, identifiants), des endpoints non documentés, des champs sensibles dans les réponses d’API (passwordHash, rôles, données internes d’autres users). Tout ce que tu envoies au client, il le lit — sans effort. Une cléNEXT_PUBLIC_STRIPE_SECRET, un objet user complet dans une réponse : c’est du cadeau. Il sait que le client est un livre ouvert.
🧭 Sur FormaCampus — Le front Next.js de FormaCampus applique une frontière stricte : les secrets (clé de l’API de paiement, secret de session, accès DB) sont sans préfixe et utilisés uniquement dans des Server Components / route handlers / Server Actions ; le module qui les expose importe
server-only(build cassé si fuite). Seules des valeurs publiques sont enNEXT_PUBLIC_(URL de l’API, clé publique du paiement, ID analytics). Les réponses d’API renvoient des DTO explicites (jamais l’entité user complète — pas depasswordHash, pas de champs internes). Un check CI analyse le bundle pour détecter toute chaîne ressemblant à un secret. Résultat : ce qui atteint le navigateur est, par conception, public et minimal.
✏️ Exercices
Exercice 1 — Trouve la fuite. Un dev veut appeler une API tierce depuis un composant et écrit dans .env : NEXT_PUBLIC_WEATHER_API_KEY=abc123, puis l’utilise dans un composant 'use client'. Où est le problème et comment faire correctement ?
✅ Solution
Problème : NEXT_PUBLIC_WEATHER_API_KEY est inlinée dans le bundle navigateur → la clé est visible par tous (DevTools, bundle). Si c’est une clé secrète (facturée, à quota, à ne pas partager), c’est une fuite : n’importe qui peut la réutiliser. Correctement : garder la clé sans préfixe (WEATHER_API_KEY, serveur uniquement) et ne pas appeler l’API tierce depuis le client. On crée un route handler (ou une Server Action) côté serveur qui, lui, utilise process.env.WEATHER_API_KEY et appelle l’API tierce ; le composant client appelle ce endpoint interne. Le secret ne quitte jamais le serveur. (Si la clé est conçue pour être publique — certaines le sont, avec restrictions de domaine — alors NEXT_PUBLIC_ est acceptable, mais il faut le vérifier explicitement.)
Exercice 2 — La prop qui fuit. Un Server Component fait <Profil user={user} /> où user vient de la base et contient passwordHash, stripeCustomerId, et un resetToken. Profil est un Client Component. Quel est le risque ?
✅ Solution
Passer user complet en prop à un Client Component sérialise et envoie tout l’objet au navigateur — y compris passwordHash, stripeCustomerId, resetToken. Ces champs sensibles deviennent lisibles dans les données d’hydratation / le HTML / les DevTools : fuite de données (hash de mot de passe exploitable hors-ligne, token de reset réutilisable → prise de compte, identifiant de paiement). Correctif : ne passer au client qu’un DTO minimal — uniquement les champs nécessaires à l’affichage ({ id, nom, avatarUrl }) — via une sélection/projection explicite côté serveur. Ne jamais sérialiser une entité complète vers le client. Règle : ce qui traverse la frontière vers le client est public.
🧠 Quiz de révision
1. Où s’exécute un Server Component vs un Client Component ?
Un Server Component (défaut) s’exécute sur le serveur (accès secrets/DB, rien de son code ne part au client). Un Client Component ('use client') voit son code envoyé au navigateur et s’y exécute. Tout ce qui vit dans/est importé par un Client Component part au client.
2. Que fait le préfixe NEXT_PUBLIC_ sur une variable d’environnement ?
NEXT_PUBLIC_ sur une variable d’environnement ?Il inline la valeur dans le bundle JavaScript envoyé au navigateur → elle devient publique (lisible par tous). À réserver aux valeurs non secrètes. Les secrets restent sans préfixe (serveur uniquement).
3. Cite trois façons dont un secret peut fuiter vers le client en Next.js.
(1) Le préfixe NEXT_PUBLIC_ sur un secret ; (2) le passage d’une valeur secrète en prop à un Client Component (sérialisée vers le navigateur) ; (3) un module contenant un secret importé par du code client ; (aussi) une réponse d’API sur-exposée (entité complète avec champs sensibles).
4. À quoi sert import 'server-only' ?
import 'server-only' ?À garantir qu’un module (contenant secrets/accès DB) n’est jamais importé côté client : si un Client Component l’importe, le build échoue. C’est un garde-fou structurel contre les fuites de code/secrets serveur vers le bundle navigateur.
5. Quelle règle mentale résume la sécurité de la frontière ?
« Le client est public — n’y mets jamais ce qui doit rester secret. » Tout ce qui atteint le navigateur (bundle, props sérialisées, réponses d’API) est lisible par l’attaquant. Les secrets et données sensibles restent exclusivement côté serveur ; on ne renvoie au client que du public et minimal (DTO).
Chapitre suivant : Server Actions & route handlers — pourquoi une Server Action est un endpoint réseau qu’il faut valider et autoriser comme n’importe quelle API.