Retour au cours

backend / nodejs

Authentification (JWT et sessions)

Leçon 111 exercice

Explication

Ce que vous allez apprendre

  • Hasher un mot de passe correctement avec bcrypt, sans jamais le stocker en clair
  • Générer et vérifier un JWT (access token + refresh token) pour une authentification stateless
  • Comprendre le compromis entre JWT (stateless) et sessions (stateful, avec Redis)
  • Utiliser tokenVersion pour permettre une révocation globale immédiate
  • Sécuriser un cookie de session avec httpOnly et secure

Dans quel contexte ?

Une startup lance une API consommée à la fois par une application mobile et un site web. L'équipe backend doit choisir entre stocker l'état de connexion côté serveur (sessions classiques) ou signer un token que chaque client conserve lui-même (JWT). Le choix impacte directement la scalabilité de l'infrastructure (ajouter des serveurs sans se soucier d'un état partagé) et la capacité à déconnecter un utilisateur de force sur tous ses appareils.

D'abord, une règle absolue sur les mots de passe

Si une base de données est un jour compromise, les mots de passe stockés en clair peuvent être immédiatement lus par l'attaquant. Même hashés avec un algorithme rapide comme MD5 ou SHA seul, ils peuvent être cassés très rapidement.

Piège fréquent

Ne jamais utiliser MD5 ou SHA-256 seul pour hasher un mot de passe : ces algorithmes sont conçus pour être RAPIDES, ce qui permet à un attaquant de tester des milliards de combinaisons par seconde sur une carte graphique. bcrypt (ou argon2) est délibérément lent, ce qui rend cette attaque impraticable.

bcrypt est conçu délibérément pour être LENT. Il intègre aussi un "sel" unique à chaque mot de passe, rendant les attaques par force brute beaucoup plus coûteuses en temps de calcul pour un attaquant.

Une fois les mots de passe protégés, il faut décider comment authentifier chaque requête suivante. Un JWT (JSON Web Token) fonctionne comme un badge d'accès signé numériquement.

Il contient des informations (identité, rôle) et une signature qui prouve qu'il n'a pas été falsifié. Le serveur n'a PAS besoin de stocker cette information ailleurs pour le vérifier : il suffit de vérifier la signature.

C'est ce qu'on appelle "stateless", et ça facilite énormément la scalabilité horizontale. N'importe quel serveur peut vérifier le token sans partager d'état avec les autres.

Mais ce choix a un vrai défaut à connaître : un JWT signé ne peut PAS être "annulé" facilement avant son expiration. La solution pratique est un compromis en deux temps.

Un access token à durée de vie TRÈS courte (15 minutes) limite les dégâts s'il est volé. Il est combiné à un refresh token plus long, qui permet d'en obtenir un nouveau sans redemander le mot de passe.

Le champ tokenVersion ajoute une sécurité supplémentaire : la révocation globale immédiate. En l'incrémentant, on invalide d'un coup tous les refresh tokens émis avant ce changement — utile pour une déconnexion forcée sur tous les appareils.

Il existe une approche différente, à l'opposé : les sessions "stateful". Une session classique ne stocke qu'un identifiant dans le cookie du client, toutes les vraies données restent côté serveur, souvent dans Redis.

L'avantage est une révocation immédiate, en supprimant simplement la session côté serveur. L'inconvénient est qu'elle nécessite un store partagé accessible par toutes les instances du serveur.

CritèreJWT (stateless)Sessions (stateful)
Révocation immédiateDifficile (nécessite tokenVersion)Facile (suppression côté serveur)
Scalabilité horizontaleSimple, aucun état partagéNécessite un store partagé (Redis)
Donnée stockée côté clientIdentité + rôle (signés)Un simple identifiant

Le piège de sécurité fondamental à ne jamais oublier, quel que soit le choix : un cookie de session doit toujours être httpOnly (protection contre le vol via XSS) et secure en production (transmis uniquement en HTTPS). Cette leçon s'appuie directement sur la précédente : les bases de données stockent les identités, l'authentification décide qui a le droit d'y accéder.

Commandes & code

Authentification (JWT et sessions)

js
// Hashage de mot de passe — bcrypt, jamais de stockage en clair
import bcrypt from "bcrypt";

export async function hashPassword(plain) {
  const SALT_ROUNDS = 12;
  return bcrypt.hash(plain, SALT_ROUNDS);
}

export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}
js
// Authentification par JWT — stateless, adapté aux APIs consommées par des clients variés
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";

export function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: ACCESS_TOKEN_TTL }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, tokenVersion: user.tokenVersion },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: REFRESH_TOKEN_TTL }
  );

  return { accessToken, refreshToken };
}

export function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Token manquant" });
  }

  const token = authHeader.slice(7);

  try {
    req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: "Token invalide ou expiré" });
  }
}
js
// Endpoint de refresh — renouveler l'access token sans redemander le mot de passe
app.post("/auth/refresh", async (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) return res.status(401).json({ error: "Refresh token manquant" });

  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const user = await db.users.findById(payload.sub);

    // invalide si l'utilisateur a explicitement révoqué ses tokens (logout global)
    if (!user || user.tokenVersion !== payload.tokenVersion) {
      return res.status(401).json({ error: "Session révoquée" });
    }

    const tokens = generateTokens(user);
    res.json({ accessToken: tokens.accessToken });
  } catch {
    res.status(401).json({ error: "Refresh token invalide" });
  }
});
js
// Authentification par session — avec cookie signé, stateful côté serveur (Redis)
import session from "express-session";
import RedisStore from "connect-redis";
import { createClient } from "redis";

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 24, // 24h
    },
  })
);

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: "Identifiants invalides" });

  req.session.userId = user.id; // persisté côté serveur (Redis), seul l'ID de session est dans le cookie
  res.json({ ok: true });
});

function requireSession(req, res, next) {
  if (!req.session.userId) return res.status(401).json({ error: "Non authentifié" });
  next();
}
CritèreJWT (stateless)Sessions (stateful)
Révocation immédiateDifficile (nécessite une blocklist)Immédiate (suppression côté serveur)
Scalabilité horizontaleSimple, pas de store partagé requisNécessite un store partagé (Redis)
Charge utilePeut embarquer des claims (rôle, permissions)Juste un ID, données côté serveur

Résumé

  • Les mots de passe se hashent avec bcrypt (jamais MD5/SHA seul, jamais en clair).
  • JWT : access token court (15 min) + refresh token long, avec tokenVersion pour permettre une révocation globale.
  • Sessions : cookie httpOnly/secure contenant seulement un ID, données réelles dans un store partagé (Redis).
  • Le choix JWT vs session dépend du besoin de révocation immédiate et de l'architecture (mobile/SPA vs monolithe web).

Exercices pratiques

1 disponible
1

Mission : un utilisateur banni reste connecté malgré la révocation

Objectif : Diagnostiquer pourquoi la révocation d'un compte n'invalide pas ses tokens déjà émis, puis corriger le hashage de mot de passe et sécuriser le cookie de refresh token.

Contexte

L'équipe support signale qu'un utilisateur banni pour fraude reste capable d'appeler l'API pendant encore 15 minutes après le bannissement, tant que son access token n'a pas expiré, ce qui est jugé normal. Le vrai problème est ailleurs : même après ces 15 minutes, l'endpoint /auth/refresh continue de lui délivrer de nouveaux access tokens valides, indéfiniment. En parallèle, tu découvres que hashPassword utilise crypto.createHash('sha256').update(plain).digest('hex') au lieu de bcrypt, et que le cookie contenant le refresh token est créé sans option httpOnly.

Résoudre l’exercice →