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
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
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
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
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.Le cookie de session sécurisé
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
- Ton app génère un
code_verifier(aléatoire) et soncode_challenge(hash). Stockestateetnonceen session. - Redirige l'utilisateur·ice vers le provider avec
code_challenge,state,nonce. - L'utilisateur·ice s'authentifie chez le provider et donne son consentement.
- Le provider redirige vers ton callback avec un
code. - Tu vérifies que le
statereçu correspond à celui en session. - Tu échanges le
codecontre unid_tokenetaccess_token, en envoyant lecode_verifier. - Tu valides le
id_token(signature,nonce,iss,aud,exp). - Tu crées la session locale, tu poses ton cookie.
Vérifications obligatoires
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 5
Magic links et passwordless
Pas de mot de passe : on envoie un lien à usage unique par email. Pratique pour l'utilisateur·ice, à sécuriser correctement côté serveur.
Le principe
- L'utilisateur·ice saisit son email
- Tu génères un token aléatoire (32 bytes minimum), tu en stockes le hash en base
- Tu envoies le token (en clair) dans un lien par email
- Au clic, tu vérifies le hash, tu invalides le token, tu connectes
Sécurité du token
- Aléatoire cryptographique :
crypto.randomBytes, pasMath.random - 32 bytes minimum (256 bits d'entropie)
- Stocké hashé en base (un attaquant qui dump la DB ne peut pas se logger)
- Single-use : invalidé dès la première utilisation
- TTL court : 10 à 15 minutes maximum
- Nouveau lien généré : invalide l'ancien
import crypto from 'crypto';
import argon2 from 'argon2';
// Génération
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = await argon2.hash(token);
await db.magicLink.create({
data: {
tokenHash,
userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 min
},
});
// Envoyer l'email avec le token (en clair) dans l'URL
const url = `https://app.fr/magic?token=${token}`;
await sendEmail({ to: user.email, subject: 'Ton lien de connexion', html: `<a href="${url}">Se connecter</a>` });Rate limit obligatoire
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
- Registration : l'utilisateur·ice crée une passkey, le navigateur génère une paire de clés, envoie la clé publique au serveur
- 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
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
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
🛠️ 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
- /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. - /login : reçoit
email+password. Cherche l'utilisateur. Compare avec argon2.verify. Si OK, crée une session, pose le cookie. - 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).
- Rate limit : 5 tentatives de login par IP toutes les 15 minutes.
- Cookie sécurisé :
HttpOnly,Secure,SameSite=lax. - 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
Quel algorithme utiliser pour hasher un mot de passe en 2025 ?
- 2
Où stocker un JWT côté client ?
- 3
Quels flags un cookie de session doit-il obligatoirement avoir ?
- 4
Quel flow OAuth2 utiliser pour une SPA ou une app mobile ?
- 5
Quelle est la durée de vie maximale d'un token de password reset ?
- 6
Pourquoi renvoyer la même réponse que l'email existe ou non sur /forgot-password ?
- 7
MFA par SMS, bonne idée ?
- 8
Avantage principal des passkeys ?
- 9
Après un password reset réussi, que faire des sessions actives ?
- 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 →