Anaïs Sparesotto
Sécurité · AuthAvancé≈ 3h · 8 chapitres

Authentification sécurisée et gestion des utilisateur·ices

Hash de mots de passe, sessions vs JWT, OAuth, magic links, passkeys, MFA, reset password. Pour implémenter une auth qui ne fuite pas.

À la fin du cours, tu sais

  • Savoir quand utiliser une lib d'auth plutôt que coder soi-même
  • Hasher correctement un mot de passe (argon2id / bcrypt)
  • Choisir entre sessions serveur et JWT, et le bon cookie
  • Mettre en place OAuth2 + PKCE pour Sign in with Google/GitHub
  • Comprendre les passkeys et la sortie du mot de passe
  • Implémenter MFA, password reset et rate limiting sans faille

Prérequis

  • Connaître Node.js / Express ou équivalent
  • Avoir suivi le cours OWASP Top 10 (ou notions équivalentes)
  • Comprendre cookies, HTTPS, headers HTTP

Chapitre 1

Pourquoi tu ne devrais (presque) jamais coder l'auth de zéro

L'authentification est le composant le plus attaqué de ton app. Une faille = fuite, RGPD, réputation. Le réflexe par défaut : ne pas coder soi-même.

La surface d'attaque est énorme

  • Credential stuffing (réutilisation de mots de passe leakés)
  • Brute force sur le login
  • Vol de session (XSS, CSRF, session fixation)
  • Énumération de comptes (différentes réponses selon que l'email existe)
  • Attaques sur le password reset (token réutilisable, devinable)
  • Race conditions sur les MFA

Libs et services à considérer

  • Lucia : lib auth open source, contrôle total, code dans ton repo
  • Auth.js (ex NextAuth) : standard de l'écosystème Next.js, multi-providers
  • Clerk / WorkOS / Supabase Auth : services managés, prêts à l'emploi
  • Keycloak / ZITADEL : auth self-hosted entreprise

Si tu codes l'auth toi-même

Suis a minima le standard OWASP ASVS niveau 2 (Application Security Verification Standard). C'est la checklist de référence. Le reste de ce cours te donne les bases, mais l'ASVS est plus exhaustif et tenu à jour.

Chapitre 2

Hash des mots de passe : bcrypt vs argon2

Un mot de passe ne se stocke jamais en clair. Et pas avec une simple fonction de hash : il faut une fonction lente, salée, conçue pour résister au brute force.

Les algos recommandés en 2025

  • argon2id : recommandé OWASP, résistant aux attaques GPU/ASIC, paramètres modernes
  • bcrypt : éprouvé, présent partout, cost factor 12+ en 2025
  • scrypt : aussi bon, moins répandu côté JS

Ce qu'on ne fait JAMAIS

Jamais MD5, SHA-1, SHA-256 simple, ni chiffrement réversible. Ces fonctions sont trop rapides : un attaquant teste des milliards de hashs par seconde sur GPU. Toujours saler (les libs le font automatiquement).

argon2id en Node.js

import argon2 from 'argon2';

// Hash à l'inscription
const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456,    // 19 MiB (recommandation OWASP)
  timeCost: 2,
  parallelism: 1,
});

// Vérification au login
const ok = await argon2.verify(hash, passwordSaisi);

bcrypt en Node.js

import bcrypt from 'bcrypt';

// Cost factor 12 = ~250ms par hash (acceptable)
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(passwordSaisi, hash);

Migration en douceur

Si tu hérites d'une base avec d'anciens hashs (MD5, SHA-1) : au prochain login réussi, re-hash avec argon2id et stocke en parallèle. Tu fais une migration progressive sans demander aux utilisateur·ices de changer leur mot de passe.

Chapitre 3

Sessions vs JWT et le bon cookie

Deux modèles, deux philosophies. Le choix par défaut en 2025 reste les sessions serveur, pas les JWT.

Sessions serveur

  • ID de session aléatoire (32 bytes) en cookie
  • État (userId, expiration, données) stocké côté serveur (DB ou Redis)
  • Révocation instantanée : tu supprimes la session, l'utilisateur·ice est déconnecté·e
  • Plus simple à mettre en place, plus sûr par défaut

JWT (JSON Web Tokens)

  • Stateless : tout est dans le token signé
  • Pratique en microservices ou architectures distribuées
  • Plus dur à révoquer : le token est valide jusqu'à expiration, sauf à maintenir une blacklist côté serveur
  • Risque de vol si mal stocké

Le piège mortel : JWT en localStorage

Jamais de JWT en localStorage. Une XSS = vol immédiat du token, et tu ne peux pas le révoquer. JWT (si tu l'utilises) doit être dans un cookie HttpOnly, Secure, SameSite.
import crypto from 'crypto';

// 1. Générer un ID de session aléatoire et imprévisible
const sessionId = crypto.randomBytes(32).toString('base64url');

// 2. Stocker la session côté serveur
await db.session.create({
  data: {
    id: sessionId,
    userId: user.id,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  },
});

// 3. Poser le cookie avec TOUS les flags
res.cookie('sid', sessionId, {
  httpOnly: true,                  // inaccessible en JS (mitige XSS)
  secure: true,                    // HTTPS uniquement
  sameSite: 'lax',                 // mitige CSRF (lax suffit pour la plupart)
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
});

Recommandation par défaut : sessions serveur. JWT uniquement si tu sais pourquoi tu en as besoin (multi-services, mobile sans backend session).

Chapitre 4

OAuth2 et OIDC : Sign in with Google/GitHub

Déléguer l'auth à un provider de confiance, c'est souvent plus sûr que de gérer des mots de passe. Et beaucoup plus simple pour l'utilisateur·ice.

OAuth2 vs OIDC

  • OAuth2 : autorisation. Permet à une app A d'agir au nom de l'utilisateur·ice sur une app B
  • OIDC (OpenID Connect) : couche identité par-dessus OAuth2. Te donne qui est l'utilisateur·ice (id, email, profil)
  • Pour Sign in with..., on utilise toujours OIDC

Le flow recommandé : Authorization Code + PKCE

  1. Ton app génère un code_verifier (aléatoire) et son code_challenge (hash). Stocke state et nonce en session.
  2. Redirige l'utilisateur·ice vers le provider avec code_challenge, state, nonce.
  3. L'utilisateur·ice s'authentifie chez le provider et donne son consentement.
  4. Le provider redirige vers ton callback avec un code.
  5. Tu vérifies que le state reçu correspond à celui en session.
  6. Tu échanges le code contre un id_token et access_token, en envoyant le code_verifier.
  7. Tu valides le id_token (signature, nonce, iss, aud, exp).
  8. Tu crées la session locale, tu poses ton cookie.

Vérifications obligatoires

Toujours valider state (anti-CSRF) et nonce (anti-replay) au retour du provider. Sans ces vérifications, un attaquant peut forcer ton app à logger sa propre identité chez l'utilisateur·ice cible.

Libs recommandées

  • openid-client : référence Node.js, certifié OpenID
  • arctic : minimaliste, multi-providers (Google, GitHub, Discord, etc.)
  • Auth.js : full-featured, intégration Next.js native

Chapitre 6

WebAuthn et passkeys : la nouvelle référence

Les passkeys remplacent le mot de passe par une clé cryptographique liée à l'appareil (Touch ID, Face ID, YubiKey). Standard W3C, supporté par Apple, Google, Microsoft.

Pourquoi c'est révolutionnaire

  • Résistant au phishing par design : la clé est liée au domaine, impossible à utiliser sur un site pirate
  • Pas de secret partagé côté serveur : seule une clé publique est stockée. Un dump de DB ne donne rien à l'attaquant
  • UX simple : Touch ID ou Face ID, c'est tout
  • Synchronisé entre appareils via iCloud Keychain, Google Password Manager...

Le flux en 2 phases

  1. Registration : l'utilisateur·ice crée une passkey, le navigateur génère une paire de clés, envoie la clé publique au serveur
  2. Authentification : le serveur envoie un challenge, l'appareil signe avec la clé privée, le serveur vérifie avec la clé publique

Lib recommandée

// Côté serveur : @simplewebauthn/server
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

// Côté client : @simplewebauthn/browser
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';

Stratégie de migration

Propose les passkeys comme option additionnelle au début, puis par défaut. Garde un fallback (magic link ou OAuth) pour la récupération en cas de perte d'appareil. Apple, Google, GitHub utilisent cette stratégie.

Chapitre 7

MFA : TOTP et recovery codes

Si tu gardes le mot de passe, ajoute un second facteur. Idéalement TOTP (Time-based One-Time Password) ou passkey, jamais SMS.

TOTP avec une app authenticator

  • Standard RFC 6238
  • Compatible Google Authenticator, Authy, 1Password, Bitwarden
  • Code à 6 chiffres qui change toutes les 30 secondes
  • Tu génères un secret au moment de l'activation, encodé en QR code
import { authenticator } from 'otplib';
import QRCode from 'qrcode';

// 1. Génération du secret à l'activation
const secret = authenticator.generateSecret();
await db.user.update({
  where: { id: user.id },
  data: { totpSecret: secret, totpEnabled: false },
});

// 2. Affichage QR code à scanner
const otpauth = authenticator.keyuri(user.email, 'MonApp', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth);

// 3. Vérification du code à la connexion
const valid = authenticator.verify({ token, secret });

SMS, à éviter

Le MFA par SMS est vulnérable au SIM swap (un attaquant clone ta carte SIM via ingénierie sociale chez l'opérateur). À garder en dernier recours pour les comptes peu sensibles, pas en option principale.

Recovery codes

  • Génère 8 à 10 codes au moment de l'activation TOTP
  • Affiche-les une seule fois, demande à l'utilisateur·ice de les sauvegarder
  • Stocke-les hashés en base (comme des mots de passe)
  • Single-use : chaque code n'est utilisable qu'une fois
  • Permets de regénérer la liste (invalide tous les anciens)

Chapitre 8

Password reset, rate limiting, gestion de session

Le password reset est la porte d'entrée préférée des attaquants. La gestion de session, c'est ce qui sépare une auth correcte d'une auth solide.

Password reset sans faille

  • Token aléatoire 32 bytes minimum, stocké hashé
  • Usage unique et TTL court (15 minutes max)
  • Réponse identique que l'email existe ou non (anti-énumération)
  • Invalider toutes les sessions actives après reset (sécurité en cas de compte compromis)
  • Logger la demande de reset (IP, timestamp)
app.post('/forgot-password', async (req, res) => {
  const { email } = req.body;

  const user = await db.user.findUnique({ where: { email } });

  // Réponse identique même si user n'existe pas (anti-énumération)
  if (user) {
    const token = crypto.randomBytes(32).toString('base64url');
    const tokenHash = await argon2.hash(token);

    await db.passwordReset.create({
      data: {
        tokenHash,
        userId: user.id,
        expiresAt: new Date(Date.now() + 15 * 60 * 1000),
      },
    });

    await sendResetEmail(user.email, token);
  }

  // Réponse identique dans les deux cas
  res.json({ message: 'Si cet email existe, un lien a été envoyé.' });
});

Rate limiting sur les endpoints sensibles

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,    // 15 min
  max: 5,                       // 5 tentatives par IP
  standardHeaders: true,
  legacyHeaders: false,
  message: 'Trop de tentatives, réessaie dans 15 minutes.',
});

app.post('/login', loginLimiter, handler);
app.post('/forgot-password', rateLimit({ windowMs: 60*60_000, max: 3 }), handler);

Logout et déconnexion globale

  • Logout = supprime la session côté serveur, pas seulement le cookie. Sinon, un attaquant qui a volé le cookie reste connecté
  • Déconnexion globale = supprime toutes les sessions de l'utilisateur·ice (sur tous les appareils). À proposer dans les paramètres
  • Invalider toutes les sessions après changement de mot de passe ou email

Réflexes à garder

Hash argon2id + cookie HttpOnly Secure SameSite + rate limit sur login et reset + token reset usage-once + invalidation sessions après changement sensible. Ces 5 réflexes couvrent 90 % des risques.

🛠️ Exercice optionnel

Implémenter signup + login sécurisés en Express

Tu vas écrire un mini-backend Express avec deux endpoints : /signup et /login. L'objectif : hash argon2id, session serveur, cookie sécurisé, rate limit sur le login.

Ta mission

  1. /signup : reçoit email + password. Valide que le mot de passe fait au moins 12 caractères. Hash avec argon2id. Crée l'utilisateur·ice.
  2. /login : reçoit email + password. Cherche l'utilisateur. Compare avec argon2.verify. Si OK, crée une session, pose le cookie.
  3. Protection anti-énumération : que l'email existe ou non, ta réponse en cas d'échec doit être identique (et le temps de réponse aussi : utilise une vérification factice si l'utilisateur n'existe pas).
  4. Rate limit : 5 tentatives de login par IP toutes les 15 minutes.
  5. Cookie sécurisé : HttpOnly, Secure, SameSite=lax.
  6. Bonus : ajoute /logout (supprime la session) et /logout-all (supprime toutes les sessions de l'utilisateur).

Tu bloques ? Des indices, à dévoiler quand tu en as besoin.

Indice 1

Indice masqué.

Indice 2

Indice masqué.

Indice 3

Indice masqué.

✅ QCM de fin de cours

Teste tes acquis

10 questions, plusieurs réponses parfois possibles. Coche tout ce qui te semble juste, puis valide pour voir ton score et les explications.

  1. 1

    Quel algorithme utiliser pour hasher un mot de passe en 2025 ?

  2. 2

    Où stocker un JWT côté client ?

  3. 3

    Quels flags un cookie de session doit-il obligatoirement avoir ?

  4. 4

    Quel flow OAuth2 utiliser pour une SPA ou une app mobile ?

  5. 5

    Quelle est la durée de vie maximale d'un token de password reset ?

  6. 6

    Pourquoi renvoyer la même réponse que l'email existe ou non sur /forgot-password ?

  7. 7

    MFA par SMS, bonne idée ?

  8. 8

    Avantage principal des passkeys ?

  9. 9

    Après un password reset réussi, que faire des sessions actives ?

  10. 10

    Quel rate limit raisonnable sur /login ?

Tu peux laisser des questions sans réponse, elles compteront comme fausses.

Tu veux ce cours pour ton équipe ?

Je peux adapter et animer ce cours pour tes formateur·ices ou tes apprenant·es, en présentiel ou en distanciel. Parlons-en pendant l'audit gratuit.

Réserver un audit gratuit →