Authentification sécurisée avec Claude Code : sessions Next.js, JWT et OAuth
Implémentez une auth sûre avec Claude Code : sessions Next.js, JWT, OAuth, CSRF, RBAC, logs d'audit et tests.
L’authentification ne se limite pas à un formulaire de connexion. Avant publication, il faut traiter le stockage des mots de passe, la durée de session, les cookies, les JWT, les callbacks OAuth, la protection CSRF, la réinitialisation de mot de passe, le RBAC, les secrets, les logs d’audit et les tests. Demander simplement à Claude Code “ajoute l’auth” peut produire une page qui marche, tout en cachant des risques sérieux.
Le raccourci dangereux le plus fréquent consiste à stocker un JWT longue durée dans localStorage. Si une faille XSS permet à du JavaScript malveillant de tourner dans la page, ce token peut être lu et réutilisé. Pour une application web classique, une session côté serveur avec un identifiant opaque envoyé dans un cookie HttpOnly est généralement plus facile à révoquer, auditer et tester.
Ce guide propose une implémentation Next.js App Router copiable : validation Zod, hash bcrypt, cookie de session signé, middleware guard, vérification CSRF et Origin, RBAC, audit log et tests Vitest. Le JWT reste une frontière courte durée pour API ou mobile. OAuth est une frontière de fournisseur d’identité, pas un remplacement de l’autorisation locale.
Vérifiez toujours les sources officielles : Next.js Authentication guide, API cookies de Next.js, OWASP Authentication Cheat Sheet, OWASP Password Storage Cheat Sheet, OWASP Forgot Password Cheat Sheet, MDN Secure cookie configuration, Auth.js et Claude Code docs. Pour compléter, lisez gestion des cookies, RBAC et validation Zod.
Séparer session, JWT et OAuth
Une session répond à la question “ce navigateur est-il toujours connecté ?”. Un JWT répond “ce client peut-il prouver des claims signés pendant une courte durée ?”. OAuth/OIDC répond “un fournisseur fiable a-t-il authentifié cette personne ?”. Mélanger ces responsabilités rend la revue de code fragile.
| Mécanisme | Cas adapté | Force | Risque à contrôler |
|---|---|---|---|
| Session serveur | Dashboard SaaS, espace membre, admin | Révocation et audit simples | Besoin de Redis, Postgres, DynamoDB ou autre store |
| JWT | API mobile, appel court entre services, API externe | Vérification sans base de données | Révocation plus difficile, stockage long navigateur à éviter |
| OAuth / OIDC | Google, GitHub, SSO entreprise | Vous ne stockez pas le mot de passe | Le login fournisseur ne remplace pas les droits locaux |
Trois cas d’usage reviennent souvent. Pour un dashboard SaaS, gardez l’état de login dans un cookie HttpOnly et exigez une réauthentification avant facturation ou changement d’e-mail. Pour un site de contenu payant, séparez lecteurs gratuits, clients, éditeurs et admins par RBAC avec logs. Pour un outil interne B2B, Google Workspace ou Entra ID peut authentifier, mais l’application doit vérifier tenant, rôle, expiration et révocation.
Prompt de sécurité pour Claude Code
Avant le code, donnez le contrat de sécurité.
Implémente l'authentification pour Next.js App Router.
Exigences:
- Login navigateur avec session serveur et cookie HttpOnly
- JWT limité aux tokens courts d'API externe; pas de stockage localStorage
- Mots de passe hashés avec bcrypt ou Argon2id, jamais en clair
- Validation Zod et aucun message révélant si l'e-mail existe
- Cookies avec Secure, HttpOnly, SameSite, Path et Max-Age explicites
- API modifiant l'état avec Origin check et token CSRF
- OAuth via Auth.js ou bibliothèque éprouvée, pas d'implémentation maison complète
- Inclure RBAC, password reset, audit logs et tests
- Lister les pièges et les commandes de vérification
Ce prompt empêche les raccourcis. Il indique à Claude Code quelles décisions sont obligatoires et quelles décisions sont interdites.
Implémentation Next.js minimale
Installez les dépendances.
npm install zod bcryptjs
npm install -D vitest typescript @types/node
Ajoutez un secret d’au moins 32 caractères dans .env.local. Ne le commitez pas.
SESSION_SECRET="replace-with-at-least-32-random-characters"
Créez lib/auth/password.ts. OWASP recommande Argon2id comme choix moderne ; ici bcryptjs rend la démo facile à copier.
import bcrypt from "bcryptjs";
import { z } from "zod";
export const passwordSchema = z.string().min(12).max(128);
export async function hashPassword(password: string) {
const parsed = passwordSchema.parse(password);
return bcrypt.hash(parsed, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
Créez lib/auth/session.ts. Le Map mémoire sert seulement à la démo ; en production utilisez Redis, PostgreSQL, DynamoDB ou un store partagé.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
export type Role = "user" | "admin";
type SessionRecord = { userId: string; role: Role; csrfToken: string; expiresAt: number };
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
export const SESSION_COOKIE_NAME =
env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
function signSessionId(sessionId: string) {
return createHmac("sha256", env.SESSION_SECRET).update(sessionId).digest("base64url");
}
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
export function createSession(userId: string, role: Role = "user") {
const sessionId = randomBytes(32).toString("base64url");
const token = `${sessionId}.${signSessionId(sessionId)}`;
const csrfToken = randomBytes(32).toString("base64url");
sessions.set(sessionId, {
userId,
role,
csrfToken,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
return { token, csrfToken };
}
export function getSession(token?: string) {
if (!token) return null;
const [sessionId, signature] = token.split(".");
if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return { id: sessionId, ...session };
}
export function destroySession(token?: string) {
const sessionId = token?.split(".")[0];
if (sessionId) sessions.delete(sessionId);
}
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) throw new Error("Bad origin");
}
export function assertCsrf(request: Request, session: { csrfToken: string }) {
const submitted = request.headers.get("x-csrf-token");
if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}
Créez app/api/login/route.ts. Dans un vrai produit, le hash et le rôle viennent de la base.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { SESSION_COOKIE_NAME, createSession, sessionCookieOptions } from "@/lib/auth/session";
export const runtime = "nodejs";
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(12).max(128),
});
async function findUserByEmail(email: string) {
if (email !== "masa@example.com") return null;
return {
id: "user_123",
role: "admin" as const,
passwordHash: await hashPassword("correct-horse-battery-staple"),
};
}
export async function POST(request: NextRequest) {
const parsed = loginInputSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
const user = await findUserByEmail(parsed.data.email);
const passwordOk = user ? await verifyPassword(parsed.data.password, user.passwordHash) : false;
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createSession(user.id, user.role);
const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
response.cookies.set({ name: SESSION_COOKIE_NAME, value: session.token, ...sessionCookieOptions });
response.cookies.set({
name: "csrf-token",
value: session.csrfToken,
secure: sessionCookieOptions.secure,
sameSite: "lax",
path: "/",
maxAge: sessionCookieOptions.maxAge,
});
return response;
}
Ajoutez middleware.ts pour rediriger les visiteurs non connectés. Ce n’est pas la frontière d’autorisation finale.
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE_NAME =
process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
const pathname = request.nextUrl.pathname;
if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
Testez avec test/auth.test.ts.
import { beforeAll, describe, expect, it } from "vitest";
beforeAll(() => {
process.env.NODE_ENV = "test";
process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});
describe("auth primitives", () => {
it("hashes and verifies passwords", async () => {
const { hashPassword, verifyPassword } = await import("../lib/auth/password");
const hash = await hashPassword("correct-horse-battery-staple");
await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
});
it("creates and destroys a session", async () => {
const { createSession, destroySession, getSession } = await import("../lib/auth/session");
const session = createSession("user_123", "admin");
expect(getSession(session.token)?.role).toBe("admin");
destroySession(session.token);
expect(getSession(session.token)).toBeNull();
});
it("validates login input", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
});
});
Password reset, OAuth et JWT
La réinitialisation de mot de passe ne doit pas révéler si une adresse existe. Répondez de façon identique, générez un token aléatoire fort, stockez seulement son hash, expirez-le vite et rendez-le à usage unique. Après changement de mot de passe, proposez d’invalider les sessions existantes.
Pour OAuth, évitez l’implémentation maison. Utilisez Auth.js ou une bibliothèque éprouvée, liez l’identité fournisseur à un utilisateur local, puis créez votre session applicative. Ne transformez pas l’access token du fournisseur en cookie de session.
JWT est pratique pour une API, mais pas universel. Définissez aud, iss, exp, la rotation des clés et le plan de réaction en cas de fuite.
Pièges, audit et monétisation
Avant publication, bloquez ces erreurs : JWT longue durée dans localStorage, cookies sans Secure ni HttpOnly, mots de passe hashés avec SHA-256 simple, reset tokens en clair, messages qui révèlent si l’e-mail existe, RBAC uniquement dans middleware, logs qui impriment des secrets.
Un audit log doit contenir actor, action, résultat et date, jamais mot de passe, session ID, reset token ou OAuth access token. En SaaS, vérifiez tenantId avant le rôle.
L’authentification protège aussi les revenus : contenu premium, templates, liens Gumroad, formulaires B2B et dashboards membres. Commencez avec la checklist gratuite, utilisez produits et templates pour des ressources réutilisables, puis formation ou conseil Claude Code pour concevoir auth, RBAC, logs et CI sur un vrai repo.
Résultat pratique
Quand Masa a testé ce flux, la valeur ne venait pas seulement de la route login. Le gain venait du fait de traiter cookie de session, CSRF, RBAC, audit log et tests comme une seule unité. Une ancienne version laissait croire que middleware était une frontière de sécurité alors qu’il ne vérifiait que la présence du cookie. Les contrôles serveur ont rendu la revue plus nette.
Résumé
Avec Claude Code, commencez par les frontières. Navigateur : session serveur et cookie sûr. API : JWT court. Login externe : OAuth/OIDC avec bibliothèque. Écriture de données : CSRF et Origin. Autorisation : RBAC. Opérations sensibles : audit et tests.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.