Aller au contenu
·30 min de lecture

Authentification moderne en 2026 : JWT, sessions, OAuth et Passkeys

Le panorama complet de l'authentification : quand utiliser les sessions ou les JWT, les flux OAuth 2.0 / OIDC, la rotation des refresh tokens, les passkeys (WebAuthn), et les patterns d'authentification Next.js que j'utilise réellement.

Partager:X / TwitterLinkedIn

L'authentification est le seul domaine du développement web où « ça marche » ne suffit jamais. Un bug dans votre sélecteur de date est agaçant. Un bug dans votre système d'authentification, c'est une fuite de données.

J'ai implémenté l'authentification de zéro, migré entre des fournisseurs, débogué des incidents de vol de tokens, et géré les conséquences des décisions « on corrigera la sécurité plus tard ». Cet article est le guide complet que j'aurais aimé avoir quand j'ai commencé. Pas seulement la théorie — les vrais compromis, les vulnérabilités réelles, et les patterns qui tiennent la route sous la pression de la production.

Nous couvrirons l'ensemble du paysage : sessions, JWT, OAuth 2.0, passkeys, MFA et autorisation. À la fin, vous comprendrez non seulement comment chaque mécanisme fonctionne, mais aussi quand l'utiliser et pourquoi les alternatives existent.

Sessions vs JWT : les vrais compromis#

C'est la première décision à laquelle vous serez confronté, et Internet regorge de mauvais conseils à ce sujet. Laissez-moi exposer ce qui compte vraiment.

Authentification basée sur les sessions#

Les sessions sont l'approche originale. Le serveur crée un enregistrement de session, le stocke quelque part (base de données, Redis, mémoire), et donne au client un identifiant de session opaque dans un cookie.

typescript
// Simplified session creation
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // Store in your database or Redis
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

Les avantages sont réels :

  • Révocation instantanée. Supprimez l'enregistrement de session et l'utilisateur est déconnecté. Pas besoin d'attendre l'expiration. C'est important quand vous détectez une activité suspecte.
  • Visibilité des sessions. Vous pouvez montrer aux utilisateurs leurs sessions actives (« connecté sur Chrome, Windows 11, Istanbul ») et les laisser révoquer individuellement.
  • Taille de cookie réduite. L'identifiant de session fait typiquement 64 caractères. Le cookie ne grossit jamais.
  • Contrôle côté serveur. Vous pouvez mettre à jour les données de session (promouvoir un utilisateur en administrateur, changer les permissions) et c'est effectif dès la requête suivante.

Les inconvénients sont également réels :

  • Accès à la base de données à chaque requête. Chaque requête authentifiée nécessite une recherche de session. Avec Redis c'est en dessous de la milliseconde, mais ça reste une dépendance.
  • Le scaling horizontal nécessite un stockage partagé. Si vous avez plusieurs serveurs, ils doivent tous avoir accès au même magasin de sessions. Les sessions collantes (sticky sessions) sont un contournement fragile.
  • Le CSRF est une préoccupation. Comme les cookies sont envoyés automatiquement, vous avez besoin d'une protection CSRF. Les cookies SameSite résolvent largement ce problème, mais vous devez comprendre pourquoi.

Authentification basée sur JWT#

Les JWT inversent le modèle. Au lieu de stocker l'état de session sur le serveur, vous l'encodez dans un jeton signé que le client détient.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

Les avantages :

  • Aucun stockage côté serveur. Le jeton est autonome. Vous vérifiez la signature et lisez les claims. Aucun accès à la base de données.
  • Fonctionne entre les services. Dans une architecture microservices, tout service possédant la clé publique peut vérifier le jeton. Pas besoin de magasin de sessions partagé.
  • Scaling sans état. Ajoutez plus de serveurs sans vous soucier de l'affinité de session.

Les inconvénients — et ce sont ceux que les gens passent sous silence :

  • Vous ne pouvez pas révoquer un JWT. Une fois émis, il est valide jusqu'à son expiration. Si le compte d'un utilisateur est compromis, vous ne pouvez pas forcer la déconnexion. Vous pouvez construire une liste de blocage, mais alors vous avez réintroduit un état côté serveur et perdu l'avantage principal.
  • Taille du jeton. Les JWT avec quelques claims font typiquement plus de 800 octets. Ajoutez des rôles, des permissions et des métadonnées, et vous envoyez des kilooctets à chaque requête.
  • Le payload est lisible. Le payload est encodé en Base64, pas chiffré. N'importe qui peut le décoder. Ne mettez jamais de données sensibles dans un JWT.
  • Problèmes de décalage d'horloge. Si vos serveurs ont des horloges différentes (ça arrive), les vérifications d'expiration deviennent peu fiables.

Quand utiliser chacun#

Ma règle générale :

Utilisez les sessions quand : Vous avez une application monolithique, vous avez besoin d'une révocation instantanée, vous construisez un produit grand public où la sécurité des comptes est critique, ou vos exigences d'authentification peuvent changer fréquemment.

Utilisez les JWT quand : Vous avez une architecture microservices où les services doivent vérifier l'identité de manière indépendante, vous construisez une communication API-vers-API, ou vous implémentez un système d'authentification tiers.

En pratique : La plupart des applications devraient utiliser des sessions. L'argument « les JWT sont plus scalables » ne s'applique que si vous avez réellement un problème de scaling que le stockage de sessions ne peut pas résoudre — et Redis gère des millions de recherches de sessions par seconde. J'ai vu trop de projets choisir les JWT parce qu'ils paraissent plus modernes, puis construire une liste de blocage et un système de refresh tokens plus complexe que les sessions ne l'auraient été.

JWT en profondeur#

Même si vous choisissez l'authentification par sessions, vous rencontrerez les JWT via OAuth, OIDC et les intégrations tierces. Comprendre les mécanismes internes est incontournable.

Anatomie d'un JWT#

Un JWT comporte trois parties séparées par des points : header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Header — déclare l'algorithme et le type de jeton :

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — contient les claims. Les claims standard ont des noms courts :

json
{
  "sub": "user_123",       // Subject (de qui il s'agit)
  "iss": "https://auth.example.com",  // Issuer (qui l'a créé)
  "aud": "https://api.example.com",   // Audience (qui doit l'accepter)
  "iat": 1709312000,       // Issued At (horodatage Unix)
  "exp": 1709312900,       // Expiration (horodatage Unix)
  "role": "admin"          // Claim personnalisé
}

Signature — prouve que le jeton n'a pas été altéré. Créée en signant le header et le payload encodés avec une clé secrète.

RS256 vs HS256 : ça compte vraiment#

HS256 (HMAC-SHA256) — symétrique. Le même secret signe et vérifie. Simple, mais chaque service qui doit vérifier les jetons doit avoir le secret. Si l'un d'entre eux est compromis, un attaquant peut forger des jetons.

RS256 (RSA-SHA256) — asymétrique. Une clé privée signe, une clé publique vérifie. Seul le serveur d'authentification a besoin de la clé privée. N'importe quel service peut vérifier avec la clé publique. Si un service de vérification est compromis, l'attaquant peut lire les jetons mais pas les forger.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — use this when multiple services verify tokens
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // CRITICAL: always restrict algorithms
  });
 
  return payload;
}

Règle : utilisez RS256 chaque fois que les jetons traversent les frontières de services. Utilisez HS256 uniquement quand le même service signe et vérifie.

L'attaque alg: none#

C'est la vulnérabilité JWT la plus célèbre, et elle est d'une simplicité embarrassante. Certaines bibliothèques JWT avaient l'habitude de :

  1. Lire le champ alg du header
  2. Utiliser l'algorithme spécifié
  3. Si alg: "none", sauter la vérification de signature entièrement

Un attaquant pouvait prendre un JWT valide, modifier le payload (par exemple, mettre "role": "admin"), définir alg sur "none", supprimer la signature, et l'envoyer. Le serveur l'acceptait.

typescript
// VULNERABLE — never do this
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "No signature needed" — CATASTROPHIC
    return JSON.parse(atob(payloadB64));
  }
 
  // ... verify signature
}

La correction est simple : spécifiez toujours l'algorithme attendu explicitement. Ne laissez jamais le jeton vous dire comment le vérifier.

typescript
// SAFE — algorithm is hardcoded, not read from the token
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Only accept RS256 — ignore the header
});

Les bibliothèques modernes comme jose gèrent cela correctement par défaut, mais vous devriez quand même passer explicitement l'option algorithms comme défense en profondeur.

Attaque par confusion d'algorithme#

En lien avec ce qui précède : si un serveur est configuré pour accepter RS256, un attaquant pourrait :

  1. Obtenir la clé publique du serveur (elle est publique, après tout)
  2. Créer un jeton avec alg: "HS256"
  3. Le signer en utilisant la clé publique comme secret HMAC

Si le serveur lit le header alg et passe à la vérification HS256, la clé publique (que tout le monde connaît) devient le secret partagé. La signature est valide. L'attaquant a forgé un jeton.

Encore une fois, la correction est la même : ne faites jamais confiance à l'algorithme du header du jeton. Codez-le toujours en dur.

Rotation des refresh tokens#

Si vous utilisez des JWT, vous avez besoin d'une stratégie de refresh tokens. Envoyer un token d'accès à longue durée de vie, c'est chercher les ennuis — s'il est volé, l'attaquant a accès pendant toute sa durée de vie.

Le pattern :

  • Token d'accès : courte durée de vie (15 minutes). Utilisé pour les requêtes API.
  • Refresh token : longue durée de vie (30 jours). Utilisé uniquement pour obtenir un nouveau token d'accès.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // Groups related tokens together
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // Store refresh token record
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Rotation à chaque utilisation#

Chaque fois que le client utilise un refresh token pour obtenir un nouveau token d'accès, vous émettez un nouveau refresh token et invalidez l'ancien :

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // Token doesn't exist — possible theft
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // Token expired
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // THIS TOKEN WAS ALREADY USED.
    // Someone is replaying it — either the legitimate user
    // or an attacker. Either way, kill the entire family.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `Refresh token reuse detected for user ${record.userId}, family ${record.familyId}. All tokens in family invalidated.`
    );
 
    return null;
  }
 
  // Mark current token as used (don't delete — we need it for reuse detection)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Issue new pair with the same family ID
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Same family
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Pourquoi l'invalidation par famille est importante#

Considérez ce scénario :

  1. L'utilisateur se connecte, obtient le refresh token A
  2. L'attaquant vole le refresh token A
  3. L'attaquant utilise A pour obtenir une nouvelle paire (token d'accès + refresh token B)
  4. L'utilisateur essaie d'utiliser A (qu'il possède encore) pour rafraîchir

Sans détection de réutilisation, l'utilisateur obtient simplement une erreur. L'attaquant continue avec le token B. L'utilisateur se reconnecte, sans jamais savoir que son compte a été compromis.

Avec la détection de réutilisation et l'invalidation par famille : quand l'utilisateur essaie d'utiliser le token A déjà utilisé, le système détecte la réutilisation, invalide chaque token de la famille (y compris B), et force les deux — l'utilisateur et l'attaquant — à se réauthentifier. L'utilisateur reçoit un message « veuillez vous reconnecter » et pourrait réaliser que quelque chose ne va pas.

C'est l'approche utilisée par Auth0, Okta et Auth.js. Ce n'est pas parfait — si l'attaquant utilise le token avant l'utilisateur légitime, c'est l'utilisateur légitime qui déclenche l'alerte de réutilisation. Mais c'est le mieux qu'on puisse faire avec des jetons au porteur.

OAuth 2.0 et OIDC#

OAuth 2.0 et OpenID Connect sont les protocoles derrière « Se connecter avec Google/GitHub/Apple ». Les comprendre est essentiel même si vous utilisez une bibliothèque, car quand les choses cassent — et elles casseront — vous devez savoir ce qui se passe au niveau du protocole.

La distinction clé#

OAuth 2.0 est un protocole d'autorisation. Il répond à : « Cette application peut-elle accéder aux données de cet utilisateur ? » Le résultat est un token d'accès qui accorde des permissions spécifiques (scopes).

OpenID Connect (OIDC) est une couche d'authentification construite par-dessus OAuth 2.0. Il répond à : « Qui est cet utilisateur ? » Le résultat est un ID token (un JWT) qui contient les informations d'identité de l'utilisateur.

Quand vous « vous connectez avec Google », vous utilisez OIDC. Google dit à votre application qui est l'utilisateur (authentification). Vous pouvez aussi demander des scopes OAuth pour accéder à son calendrier ou son drive (autorisation).

Flux Authorization Code avec PKCE#

C'est le flux que vous devriez utiliser pour les applications web. PKCE (Proof Key for Code Exchange) a été conçu à l'origine pour les applications mobiles mais est maintenant recommandé pour tous les clients, y compris les applications côté serveur.

typescript
import { randomBytes, createHash } from "crypto";
 
// Step 1: Generate PKCE values and redirect the user
function initiateOAuthFlow() {
  // Code verifier: random 43-128 character string
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // Code challenge: SHA256 hash of the verifier, base64url-encoded
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: random value for CSRF protection
  const state = randomBytes(16).toString("hex");
 
  // Store both in the session (server-side!) before redirecting
  // NEVER put the code_verifier in a cookie or URL parameter
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// Step 2: Handle the callback
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // Check for errors from the provider
  if (error) {
    throw new Error(`OAuth error: ${error}`);
  }
 
  // Verify state matches (CSRF protection)
  if (state !== session.oauthState) {
    throw new Error("State mismatch — possible CSRF attack");
  }
 
  // Exchange the authorization code for tokens
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: proves we started this flow
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — for API calls to Google
  // tokens.id_token — JWT with user identity (OIDC)
  // tokens.refresh_token — for getting new access tokens
 
  // Step 3: Verify the ID token and extract user info
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

Les trois endpoints#

Chaque fournisseur OAuth/OIDC expose ceux-ci :

  1. Endpoint d'autorisation — où vous redirigez l'utilisateur pour se connecter et accorder les permissions. Retourne un code d'autorisation.
  2. Endpoint de jeton — où votre serveur échange le code d'autorisation contre les tokens d'accès/refresh/ID. C'est un appel serveur-à-serveur.
  3. Endpoint UserInfo — où vous pouvez récupérer des données de profil utilisateur supplémentaires en utilisant le token d'accès. Avec OIDC, une grande partie de ces données est déjà dans l'ID token.

Le paramètre State#

Le paramètre state empêche les attaques CSRF sur le callback OAuth. Sans lui :

  1. L'attaquant démarre un flux OAuth sur sa propre machine, obtient un code d'autorisation
  2. L'attaquant fabrique une URL : https://yourapp.com/callback?code=ATTACKER_CODE
  3. L'attaquant piège une victime pour la faire cliquer dessus (lien email, image cachée)
  4. Votre application échange le code de l'attaquant et lie le compte Google de l'attaquant à la session de la victime

Avec state : votre application génère une valeur aléatoire, la stocke dans la session, et l'inclut dans l'URL d'autorisation. Quand le callback arrive, vous vérifiez que le state correspond. L'attaquant ne peut pas forger cela car il n'a pas accès à la session de la victime.

Auth.js (NextAuth) avec le Next.js App Router#

Auth.js est ce vers quoi je me tourne en premier dans la plupart des projets Next.js. Il gère la danse OAuth, la gestion des sessions, la persistance en base de données et la protection CSRF. Voici une configuration prête pour la production.

Configuration de base#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // Use database sessions (not JWT) for better security
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60,   // Extend session every 24 hours
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Request specific scopes
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // Get refresh token
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // Email/password login (use carefully)
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Control who can sign in
    async signIn({ user, account }) {
      // Block sign-in for banned users
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Add custom fields to the session
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Fetch role from database
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Protection par middleware#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // Redirect logged-in users away from auth pages
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // Redirect unauthenticated users to login
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // Check admin role
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

Utiliser la session dans les Server Components#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

Utiliser la session dans les Client Components#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Loading...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Sign In</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "User"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkeys (WebAuthn)#

Les passkeys sont l'amélioration d'authentification la plus significative depuis des années. Elles résistent au phishing, au rejeu, et éliminent toute la catégorie des vulnérabilités liées aux mots de passe. Si vous démarrez un nouveau projet en 2026, vous devriez prendre en charge les passkeys.

Comment fonctionnent les passkeys#

Les passkeys utilisent la cryptographie à clé publique, soutenue par la biométrie ou les codes PIN de l'appareil :

  1. Inscription : Le navigateur génère une paire de clés. La clé privée reste sur l'appareil (dans une enclave sécurisée, protégée par la biométrie). La clé publique est envoyée à votre serveur.
  2. Authentification : Le serveur envoie un défi (octets aléatoires). L'appareil signe le défi avec la clé privée (après vérification biométrique). Le serveur vérifie la signature avec la clé publique stockée.

Aucun secret partagé ne traverse jamais le réseau. Il n'y a rien à hameçonner, rien à fuiter, rien à bourrer.

Pourquoi les passkeys résistent au phishing#

Quand une passkey est créée, elle est liée à l'origine (par ex. https://example.com). Le navigateur n'utilisera la passkey que sur l'origine exacte pour laquelle elle a été créée. Si un attaquant crée un site sosie à https://exarnple.com, la passkey ne sera tout simplement pas proposée. C'est appliqué par le navigateur, pas par la vigilance de l'utilisateur.

C'est fondamentalement différent des mots de passe, où les utilisateurs saisissent régulièrement leurs identifiants sur des sites de phishing parce que la page a l'air correcte.

Implémentation avec SimpleWebAuthn#

SimpleWebAuthn est la bibliothèque que je recommande. Elle gère correctement le protocole WebAuthn et a de bons types TypeScript.

typescript
// Server-side: Registration
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // Get user's existing passkeys to exclude them
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // We don't need attestation for most apps
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // Store the challenge temporarily — we need it for verification
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // 5 minute expiry
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("Challenge expired or not found");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`Registration verification failed: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }
 
  const { credential } = verification.registrationInfo;
 
  // Store the credential in the database
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // Clean up
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// Server-side: Authentication
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // If we know the user (e.g., they entered their email), limit to their passkeys
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // Store challenge for verification
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("Credential not found");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("Authentication verification failed");
  }
 
  // IMPORTANT: Update the counter to prevent replay attacks
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// Client-side: Registration
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // Get options from your server
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // This triggers the browser's passkey UI (biometric prompt)
    const credential = await webAuthnRegister(options);
 
    // Send the credential to your server for verification
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("Passkey registered successfully!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("User cancelled the passkey registration");
    }
  }
}

Attestation vs Assertion#

Deux termes que vous rencontrerez :

  • Attestation (inscription) : Le processus de création d'un nouveau credential. L'authentificateur « atteste » de son identité et de ses capacités. Pour la plupart des applications, vous n'avez pas besoin de vérifier l'attestation — définissez attestationType: "none".
  • Assertion (authentification) : Le processus d'utilisation d'un credential existant pour signer un défi. L'authentificateur « affirme » que l'utilisateur est bien celui qu'il prétend être.

Implémentation MFA#

Même avec les passkeys, vous rencontrerez des scénarios où le MFA via TOTP est nécessaire — les passkeys comme second facteur aux côtés des mots de passe, ou le support des utilisateurs dont les appareils ne prennent pas en charge les passkeys.

TOTP (mots de passe à usage unique basés sur le temps)#

Le TOTP est le protocole derrière Google Authenticator, Authy et 1Password. Il fonctionne ainsi :

  1. Le serveur génère un secret aléatoire (encodé en base32)
  2. L'utilisateur scanne un QR code contenant le secret
  3. Le serveur et l'application d'authentification calculent tous deux le même code à 6 chiffres à partir du secret et de l'heure actuelle
  4. Les codes changent toutes les 30 secondes
typescript
import { createHmac, randomBytes } from "crypto";
 
// Generate a TOTP secret for a user
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// Generate the TOTP URI for QR code
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Verify a TOTP code
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // Check current time step and adjacent ones (clock drift tolerance)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // Constant-time comparison to prevent timing attacks
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // Convert time step to 8-byte big-endian buffer
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // Dynamic truncation
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

Codes de secours#

Les utilisateurs perdent leurs téléphones. Générez toujours des codes de secours lors de la configuration du MFA :

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // 8-character hex codes
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // Hash the codes before storing — treat them like passwords
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // Return the plain codes ONCE for the user to save
  // After this, we only have the hashes
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // Mark as used — each backup code works exactly once
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

Flux de récupération#

La récupération MFA est la partie que la plupart des tutoriels omettent et que la plupart des applications réelles ratent. Voici ce que j'implémente :

  1. Principal : Code TOTP depuis l'application d'authentification
  2. Secondaire : Un des 10 codes de secours
  3. Dernier recours : Récupération par email avec un délai d'attente de 24 heures et une notification aux autres canaux vérifiés de l'utilisateur

Le délai d'attente est critique. Si un attaquant a compromis l'email de l'utilisateur, vous ne voulez pas le laisser désactiver le MFA instantanément. Le délai de 24 heures donne à l'utilisateur légitime le temps de remarquer l'email et d'intervenir.

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // Don't reveal whether the account exists
    return { message: "If that email exists, we've sent recovery instructions." };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
      status: "pending",
    },
  });
 
  // Send email with recovery link
  await sendEmail(email, {
    subject: "Account Recovery Request",
    body: `
      A request was made to disable MFA on your account.
      If this was you, click the link below after 24 hours: ...
      If this was NOT you, please change your password immediately.
    `,
  });
 
  return { message: "If that email exists, we've sent recovery instructions." };
}

Patterns d'autorisation#

L'authentification vous dit qui est quelqu'un. L'autorisation vous dit ce qu'il est autorisé à faire. Se tromper sur ce point, c'est finir dans les journaux.

RBAC vs ABAC#

RBAC (contrôle d'accès basé sur les rôles) : Les utilisateurs ont des rôles, les rôles ont des permissions. Simple, facile à comprendre, fonctionne pour la plupart des applications.

typescript
// RBAC — straightforward role checks
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // Careful with wildcards
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Usage in an API route
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (contrôle d'accès basé sur les attributs) : Les permissions dépendent des attributs de l'utilisateur, de la ressource et du contexte. Plus flexible mais plus complexe.

typescript
// ABAC — when RBAC isn't enough
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // Users can always read their own resources
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // Admins can read any resource in their department
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // Classified resources require MFA and minimum clearance
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // Destructive actions blocked outside business hours
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // Default deny
}

La règle « vérifiez à la frontière »#

C'est le principe d'autorisation le plus important : vérifiez les permissions à chaque frontière de confiance, pas seulement au niveau de l'interface utilisateur.

typescript
// BAD — only checking in the component
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // This hides the button, but doesn't prevent deletion
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>Delete</button>;
}
 
// ALSO BAD — checking in a server action but not the API route
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("Forbidden");
  await db.post.delete({ where: { id: postId } });
}
// An attacker can still call POST /api/posts/123 directly
 
// GOOD — check at every boundary
// 1. Hide the button in the UI (UX, not security)
// 2. Check in the server action (defense in depth)
// 3. Check in the API route (the actual security boundary)
// 4. Optionally, check in middleware (for route-level protection)

La vérification UI est pour l'expérience utilisateur. La vérification serveur est pour la sécurité. Ne comptez jamais sur une seule des deux.

Vérifications des permissions dans le middleware Next.js#

Le middleware s'exécute avant chaque requête correspondante. C'est un bon endroit pour le contrôle d'accès à gros grain :

typescript
// "Is this user allowed to access this section at all?"
// Fine-grained checks ("Can this user edit THIS post?") belong in the route handler
// because middleware doesn't have access to the request body or route params easily.
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // Route-level access control
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

Vulnérabilités courantes#

Ce sont les attaques que je vois le plus souvent dans les vrais codebases. Les comprendre est essentiel.

Fixation de session#

L'attaque : Un attaquant crée une session valide sur votre site, puis piège une victime pour qu'elle utilise cet identifiant de session (par exemple, via un paramètre d'URL ou en définissant un cookie via un sous-domaine). Quand la victime se connecte, la session de l'attaquant a maintenant un utilisateur authentifié.

La correction : Régénérez toujours l'identifiant de session après une authentification réussie. Ne laissez jamais un identifiant de session pré-authentification se transférer vers une session post-authentification.

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("Invalid credentials");
 
  // CRITICAL: Delete the old session and create a new one
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // Create a completely new session with a new ID
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF (Cross-Site Request Forgery)#

L'attaque : Un utilisateur est connecté à votre site. Il visite une page malveillante qui fait une requête vers votre site. Comme les cookies sont envoyés automatiquement, la requête est authentifiée.

La correction moderne : Les cookies SameSite. Définir SameSite: Lax (la valeur par défaut dans la plupart des navigateurs maintenant) empêche les cookies d'être envoyés lors de requêtes POST cross-origin, ce qui couvre la plupart des scénarios CSRF.

typescript
// SameSite=Lax covers most CSRF scenarios:
// - Blocks cookies on cross-origin POST, PUT, DELETE
// - Allows cookies on cross-origin GET (top-level navigation)
//   This is fine because GET requests shouldn't have side effects
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // This is your CSRF protection
  maxAge: 86400,
  path: "/",
});

Pour les API qui acceptent du JSON, vous bénéficiez d'une protection supplémentaire gratuite : le header Content-Type: application/json ne peut pas être défini par les formulaires HTML, et le CORS empêche le JavaScript sur d'autres origines de faire des requêtes avec des headers personnalisés.

Si vous avez besoin de garanties plus fortes (par exemple, vous acceptez des soumissions de formulaires), utilisez le pattern du cookie double-soumission ou un jeton synchroniseur. Auth.js gère cela pour vous.

Redirections ouvertes dans OAuth#

L'attaque : Un attaquant fabrique une URL de callback OAuth qui redirige vers son site après l'authentification : https://yourapp.com/callback?redirect_to=https://evil.com/steal-token

Si votre gestionnaire de callback redirige aveuglément vers le paramètre redirect_to, l'utilisateur se retrouve sur le site de l'attaquant, potentiellement avec des tokens dans l'URL.

typescript
// VULNERABLE
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... authenticate the user ...
  return Response.redirect(redirectTo); // Could be https://evil.com!
}
 
// SAFE
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // Validate the redirect URL
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... authenticate the user ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // Only allow redirects to the same origin
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // Only allow path redirects (no javascript: or data: URIs)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Fuite de jeton via le Referrer#

Si vous mettez des tokens dans les URL (ne le faites pas), ils fuiteront via le header Referer quand les utilisateurs cliquent sur des liens. Cela a causé de vraies failles, y compris chez GitHub.

Règles :

  • Ne mettez jamais de tokens dans les paramètres d'URL pour l'authentification
  • Définissez Referrer-Policy: strict-origin-when-cross-origin (ou plus strict)
  • Si vous devez mettre des tokens dans les URL (par exemple, les liens de vérification par email), rendez-les à usage unique et de courte durée
typescript
// In your Next.js middleware or layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

Injection de clé JWT#

Une attaque moins connue : certaines bibliothèques JWT supportent un header jwk ou jku qui indique au vérificateur où trouver la clé publique. Un attaquant peut :

  1. Générer sa propre paire de clés
  2. Créer un JWT avec son payload et le signer avec sa clé privée
  3. Définir le header jwk pour pointer vers sa clé publique

Si votre bibliothèque récupère et utilise aveuglément la clé du header jwk, la signature se vérifie. La correction : n'autorisez jamais le jeton à spécifier sa propre clé de vérification. Utilisez toujours des clés de votre propre configuration.

Ma stack d'authentification en 2026#

Après des années de construction de systèmes d'authentification, voici ce que j'utilise réellement aujourd'hui.

Pour la plupart des projets : Auth.js + PostgreSQL + Passkeys#

C'est ma stack par défaut pour les nouveaux projets :

  • Auth.js (v5) pour le gros du travail : fournisseurs OAuth, gestion de sessions, CSRF, adaptateur base de données
  • PostgreSQL avec l'adaptateur Prisma pour le stockage des sessions et des comptes
  • Passkeys via SimpleWebAuthn comme méthode de connexion principale pour les nouveaux utilisateurs
  • Email/mot de passe comme solution de repli pour les utilisateurs qui ne peuvent pas utiliser les passkeys
  • MFA TOTP comme second facteur pour les connexions par mot de passe

La stratégie de session est basée sur la base de données (pas JWT), ce qui me donne la révocation instantanée et une gestion de session simple.

typescript
// This is my typical auth.ts for a new project
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5 has built-in passkey support
      // This uses SimpleWebAuthn under the hood
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

Quand utiliser Clerk ou Auth0 à la place#

Je me tourne vers un fournisseur d'authentification géré quand :

  • Le projet a besoin d'un SSO entreprise (SAML, SCIM). Implémenter SAML correctement est un projet de plusieurs mois. Clerk le fait nativement.
  • L'équipe n'a pas d'expertise en sécurité. Si personne dans l'équipe ne peut expliquer PKCE, l'équipe ne devrait pas construire l'authentification de zéro.
  • Le time-to-market compte plus que le coût. Auth.js est gratuit mais prend des jours à configurer correctement. Clerk prend un après-midi.
  • Vous avez besoin de garanties de conformité (SOC 2, HIPAA). Les fournisseurs gérés gèrent la certification de conformité.

Les compromis des fournisseurs gérés :

  • Coût : Clerk facture par utilisateur actif mensuel. À grande échelle, ça s'additionne.
  • Dépendance fournisseur : Migrer hors de Clerk ou Auth0 est douloureux. Votre table d'utilisateurs est sur leurs serveurs.
  • Limites de personnalisation : Si votre flux d'authentification est inhabituel, vous vous battrez contre les opinions du fournisseur.
  • Latence : Chaque vérification d'authentification passe par une API tierce. Avec les sessions en base de données, c'est une requête locale.

Ce que j'évite#

  • Coder ma propre cryptographie. J'utilise jose pour les JWT, @simplewebauthn/server pour les passkeys, bcrypt ou argon2 pour les mots de passe. Jamais fait maison.
  • Stocker les mots de passe en SHA256. Utilisez bcrypt (facteur de coût 12+) ou argon2id. SHA256 est trop rapide — un attaquant peut essayer des milliards de hachages par seconde avec un GPU.
  • Les tokens d'accès à longue durée de vie. 15 minutes maximum. Utilisez la rotation des refresh tokens pour les sessions plus longues.
  • Les secrets symétriques pour la vérification inter-services. Si plusieurs services doivent vérifier les tokens, utilisez RS256 avec une paire de clés publique/privée.
  • Les identifiants de session personnalisés avec une entropie insuffisante. Utilisez crypto.randomBytes(32) au minimum. UUID v4 est acceptable mais offre moins d'entropie que des octets aléatoires bruts.

Hachage des mots de passe : la bonne méthode#

Puisque nous l'avons mentionné — voici comment hacher les mots de passe correctement en 2026 :

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2id is the recommended algorithm
// These are reasonable defaults for a web application
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3 iterations
    parallelism: 4,     // 4 threads
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

Pourquoi argon2id plutôt que bcrypt ? Argon2id est memory-hard, ce qui signifie que l'attaquer nécessite non seulement de la puissance CPU mais aussi de grandes quantités de RAM. Cela rend les attaques par GPU et ASIC significativement plus coûteuses. Bcrypt est encore correct — il n'est pas cassé — mais argon2id est le meilleur choix pour les nouveaux projets.

Checklist de sécurité#

Avant de mettre en production tout système d'authentification, vérifiez :

  • Les mots de passe sont hachés avec argon2id ou bcrypt (coût 12+)
  • Les sessions sont régénérées après la connexion (empêche la fixation de session)
  • Les cookies sont HttpOnly, Secure, SameSite=Lax ou Strict
  • Les JWT spécifient les algorithmes explicitement (ne faites jamais confiance au header alg)
  • Les tokens d'accès expirent en 15 minutes ou moins
  • La rotation des refresh tokens est implémentée avec détection de réutilisation
  • Le paramètre state OAuth est vérifié (protection CSRF)
  • Les URL de redirection sont validées contre une liste autorisée
  • Le rate limiting est appliqué aux endpoints de connexion, d'inscription et de réinitialisation de mot de passe
  • Les tentatives de connexion échouées sont journalisées avec l'IP mais pas avec les mots de passe
  • Le verrouillage de compte après N tentatives échouées (avec des délais progressifs, pas de verrouillage permanent)
  • Les tokens de réinitialisation de mot de passe sont à usage unique et expirent en 1 heure
  • Les codes de secours MFA sont hachés comme les mots de passe
  • Le CORS est configuré pour n'autoriser que les origines connues
  • Le header Referrer-Policy est défini
  • Aucune donnée sensible dans les payloads JWT (ils sont lisibles par n'importe qui)
  • Le compteur WebAuthn est vérifié et mis à jour (empêche le clonage de credentials)

Cette liste n'est pas exhaustive, mais elle couvre les vulnérabilités que j'ai vues le plus souvent dans les systèmes en production.

Pour conclure#

L'authentification est l'un de ces domaines où le paysage continue d'évoluer, mais les fondamentaux restent les mêmes : vérifier l'identité, émettre les credentials strictement nécessaires, vérifier les permissions à chaque frontière, et présumer la compromission.

Le changement le plus important en 2026 est la généralisation des passkeys. Le support navigateur est universel, le support plateforme (Trousseau iCloud, Gestionnaire de mots de passe Google) rend l'expérience utilisateur fluide, et les propriétés de sécurité sont véritablement supérieures à tout ce que nous avons eu auparavant. Si vous construisez une nouvelle application, faites des passkeys votre méthode de connexion principale et traitez les mots de passe comme solution de repli.

Le deuxième changement majeur est que construire sa propre authentification est devenu plus difficile à justifier. Auth.js v5, Clerk et les solutions similaires gèrent correctement les parties difficiles. La seule raison de faire du sur-mesure est quand vos exigences ne correspondent véritablement à aucune solution existante — et c'est plus rare que la plupart des développeurs ne le pensent.

Quel que soit votre choix, testez votre authentification comme le ferait un attaquant. Essayez de rejouer des tokens, de forger des signatures, d'accéder à des routes que vous ne devriez pas, et de manipuler les URL de redirection. Les bugs que vous trouvez avant le lancement sont ceux qui ne font pas les gros titres.

Articles similaires