frontend / nextjs
Authentification
Explication
Ce que vous allez apprendre
- Créer une session signée (JWT) stockée dans un cookie
httpOnly - Comprendre pourquoi
httpOnlyprotège contre le vol de session via XSS - Vérifier une session à trois niveaux : middleware, layout, et Server Action
- Rediriger un utilisateur non authentifié avec
redirect()denext/navigation - Éviter de faire confiance à un état "connecté" affiché uniquement côté client
Dans quel contexte ?
Une application interne d'entreprise donne accès à des données sensibles (factures, salaires) sous /dashboard. Un audit de sécurité externe recommandé par le client a pointé un problème classique : le token de session était stocké dans le localStorage, ce qui le rend lisible par n'importe quel script JavaScript injecté sur la page en cas de faille XSS. Cette leçon montre comment reconstruire ce système de session correctement, avec un cookie protégé et une vérification à plusieurs niveaux.
Ce que "s'authentifier" veut vraiment dire
Commençons par la définition la plus simple. S'authentifier, c'est prouver son identité une fois (login/mot de passe), puis obtenir un "laissez-passer" présenté automatiquement à chaque requête suivante.
Ce laissez-passer a un nom : la session. Ici, c'est un JWT (JSON Web Token) signé cryptographiquement, stocké dans un cookie.
Pourquoi ce cookie doit-il être httpOnly ? Ça signifie qu'il est INVISIBLE et INACCESSIBLE depuis du JavaScript exécuté dans le navigateur.
L'intérêt concret de cette restriction. Si un script malveillant s'infiltrait dans la page (attaque XSS), il ne pourrait pas voler ce cookie — contrairement à un token stocké dans le localStorage, lisible par n'importe quel script.
Piège dangereux
Stocker un token de session dans le localStorage ou le sessionStorage expose la session entière au vol dès qu'un script tiers malveillant s'exécute sur la page (via une dépendance npm compromise, par exemple). Un cookie httpOnly reste la seule option qui protège réellement contre ce scénario.
Une fois la session sécurisée, comment vérifier qu'elle est valide ? La bonne pratique n'est jamais de se reposer sur UNE seule vérification, mais sur plusieurs niveaux empilés.
Premier niveau : le middleware. Il vérifie en premier, avant même que la page ne commence à se construire — rapide, sur l'Edge Runtime.
Deuxième niveau, en filet de sécurité : le layout d'une section protégée. Il revérifie et redirige proprement si besoin, au cas où le middleware aurait été mal configuré sur une route.
Troisième niveau, le plus important pour les actions sensibles : chaque Server Action revérifie ENCORE. Elle peut potentiellement être appelée directement, en contournant complètement l'interface.
Pourquoi cette redondance n'est pas de la paranoïa ? Un seul point de défense qui échoue (mauvaise regex de matcher, oubli d'un layout) ne doit jamais suffire, à lui seul, à compromettre toute l'application.
| Niveau | Où | Rapidité | Rôle |
|---|---|---|---|
| Middleware | Edge Runtime, avant le routage | Très rapide | Premier filtre, redirige tôt |
| Layout | app/dashboard/layout.tsx | Rapide | Filet de sécurité si le middleware est mal configuré |
| Server Action | À l'intérieur de chaque action sensible | — | Dernier rempart, contre un appel direct |
Les pièges courants à éviter :
- Stocker le token dans le
localStorageplutôt que dans un cookiehttpOnly: ça expose la session au vol via XSS. - Oublier
secure: trueen production : le cookie pourrait transiter en clair sur une connexion non chiffrée. - Faire confiance à un état "connecté" affiché côté client sans revérifier côté serveur au moment d'une action sensible.
Et la suite ? Cette leçon s'appuie directement sur le middleware et les Server Actions vus précédemment, en les combinant pour bâtir un système de sécurité cohérent.
Commandes & code
Authentification
// lib/session.ts — session signée dans un cookie httpOnly (JWT)
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const secretKey = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function createSession(userId: string) {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secretKey);
const cookieStore = await cookies();
cookieStore.set("session", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
export async function getSession() {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secretKey);
return payload as { userId: string };
} catch {
return null; // token expiré ou invalide
}
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.delete("session");
}// app/login/actions.ts — Server Action de login
"use server";
import { redirect } from "next/navigation";
import { createSession } from "@/lib/session";
import { verifyCredentials } from "@/lib/auth";
export async function login(prevState: unknown, formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await verifyCredentials(email, password);
if (!user) {
return { error: "Identifiants invalides" };
}
await createSession(user.id);
redirect("/dashboard"); // redirection côté serveur après succès
}// Protection au niveau du layout — vérifie la session pour toute la section
// app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return <div className="dashboard">{children}</div>;
}// middleware.ts — première ligne de défense, avant même le rendu React
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const secretKey = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
try {
await jwtVerify(token, secretKey); // vérifie signature + expiration
return NextResponse.next();
} catch {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session");
return response;
}
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
};// Accès aux données utilisateur dans un Server Component
import { getSession } from "@/lib/session";
import { db } from "@/lib/db";
export default async function ProfilePage() {
const session = await getSession();
const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.id, session!.userId) });
return <ProfileForm user={user} />;
}Résumé
- Session en JWT signé, stockée dans un cookie
httpOnly+secure: inaccessible en JS côté client. - Vérifier la session à TROIS niveaux : middleware (rapide, edge), layout (redirect propre), et dans chaque Server Action sensible.
redirect()denext/navigationfonctionne dans les Server Components et Server Actions.- Ne jamais faire confiance à un état d'authentification stocké côté client uniquement.
Exercices pratiques
Mission : l'audit de sécurité qui trouve une session volable
Objectif : Reconstruire un système de session vulnérable en cookie httpOnly et vérifier qu'une Server Action sensible ne se fie pas uniquement au middleware.
Contexte
Un audit externe pointe deux failles sur l'application interne de factures. Premièrement, le token de session est actuellement stocké dans le localStorage via localStorage.setItem("session", token). Deuxièmement, la Server Action deleteInvoice(id) ne fait AUCUNE vérification de session en interne : elle compte uniquement sur le fait que le middleware protège déjà /dashboard/*, alors que cette action pourrait être appelée directement depuis la console du navigateur, en contournant complètement l'interface protégée par le middleware.