L’authentification TOTP (Time-based One-Time Password) est devenue le second facteur de référence pour protéger les comptes en ligne. Contrairement au SMS, vulnérable au SIM swapping, un code TOTP est calculé hors ligne sur l’appareil de l’utilisateur à partir d’un secret partagé et de l’heure courante. Dans ce tutoriel publié le 14 juin 2026, vous allez construire, en 12 étapes et environ 40 minutes, un système de double authentification (2FA) complet en Node.js : génération du secret, QR code de provisionnement, vérification avec tolérance d’horloge, chiffrement du secret au repos, codes de récupération, limitation des tentatives et protection contre le rejeu.

Le code est compatible avec Google Authenticator, Microsoft Authenticator, Authy, Ente Auth, 2FAS et tout client conforme à la RFC 6238. Vous repartirez avec un projet fonctionnel structuré autour d’une API Express, prêt à intégrer dans une application réelle.

Qu’est-ce que le TOTP et pourquoi l’adopter en 2026

Le TOTP est une extension de l’algorithme HOTP décrit par la RFC 4226 (2005). HOTP génère un mot de passe à usage unique à partir d’un secret et d’un compteur incrémental. La RFC 6238, publiée en 2011, remplace ce compteur par une valeur dérivée du temps : le système prend l’heure Unix courante, la divise par un pas de temps (30 secondes par défaut) et utilise le résultat comme compteur. Toutes les 30 secondes, un nouveau code à 6 chiffres apparaît, identique sur le serveur et sur l’application mobile tant que leurs horloges sont synchronisées.

Cette approche élimine la dépendance au réseau. Aucun SMS n’est envoyé, aucun appel d’API n’est nécessaire au moment de la validation. Le secret n’est échangé qu’une seule fois, lors de l’enrôlement, via un QR code. Ensuite, le téléphone et le serveur calculent le même code chacun de leur côté. En 2026, l’ANSSI et l’ENISA recommandent toujours la 2FA basée sur une application plutôt que par SMS, jugée beaucoup plus résistante à l’interception.

Le TOTP présente trois avantages décisifs pour un développeur. Premièrement, il est gratuit : aucun coût par message, contrairement aux passerelles SMS. Deuxièmement, il fonctionne hors ligne, même dans un avion ou une zone blanche. Troisièmement, il s’appuie sur des primitives cryptographiques standardisées (HMAC-SHA1, et optionnellement SHA-256 ou SHA-512) implémentées dans la bibliothèque standard crypto de Node.js. Le seul prérequis côté utilisateur est une application d’authentification, désormais installée par défaut sur la plupart des smartphones.

Pour replacer le TOTP dans le paysage de l’authentification forte, comparez-le aux autres méthodes courantes. Les passkeys (clés d’accès FIDO2) offrent une sécurité supérieure contre le hameçonnage, mais leur déploiement reste partiel et la 2FA TOTP demeure le filet de sécurité le plus universel.

Méthode 2FARésistance au hameçonnageHors ligneCoûtAdoption
SMSFaible (SIM swap)NonPayant par messageTrès large
TOTP (application)MoyenneOuiGratuitLarge
Notification pushMoyenne (fatigue MFA)NonVariableCroissante
Clé matérielle FIDO2ÉlevéeOuiCoût matérielEn hausse
PasskeyÉlevéeOuiGratuitÉmergente

Prérequis et versions logicielles

Avant de commencer, installez les versions suivantes. Ce tutoriel a été testé avec Node.js 24 (ligne LTS « Krypton ») et la dernière version stable de chaque dépendance disponible en juin 2026. Vérifiez votre version de Node avec node --version. Si vous utilisez une version antérieure à Node 20, mettez-la à jour : l’API node:crypto et la prise en charge native d’ESM y sont plus stables.

Outil / paquetVersion utiliséeRôle dans le projet
Node.js24.x LTS (Krypton)Environnement d’exécution
otplib13.4.1Génération et vérification TOTP
qrcode1.5.4Création du QR code de provisionnement
express5.2.1Serveur d’API HTTP
express-rate-limit8.5.2Limitation des tentatives de vérification

Un mot sur le choix des bibliothèques. La bibliothèque otplib (version 13.4.1, publiée fin mai 2026) est activement maintenue et constitue aujourd’hui la référence pour le TOTP en Node.js. Vous croiserez souvent speakeasy dans d’anciens tutoriels, mais son dernier paquet publié remonte à 2016 (version 2.0.0). Nous l’évitons ici au profit d’otplib, plus à jour et mieux typé. Côté QR code, qrcode 1.5.4 génère un data URI directement intégrable dans une balise image côté client.

Vous aurez aussi besoin de notions de base en JavaScript asynchrone (async/await), d’un terminal et d’un éditeur de code. Aucune base de données n’est requise pour suivre le tutoriel : nous simulons le stockage avec un objet en mémoire, puis nous expliquons comment passer en production. Si l’authentification par jetons vous est inconnue, lisez d’abord notre guide sur l’authentification JWT en Node.js, car nous protégerons la route d’activation 2FA avec une session authentifiée.

Comment fonctionne l’algorithme TOTP en détail

Comprendre l’algorithme avant de l’implémenter évite la majorité des bugs. Le calcul d’un code TOTP se déroule en quatre temps. D’abord, le serveur et le client partagent un secret aléatoire d’au moins 128 bits (160 bits recommandés par la RFC 4226), encodé en Base32 selon la RFC 4648 pour être lisible par les applications d’authentification. Ensuite, on calcule le compteur temporel : T égale l’heure Unix actuelle moins T0, divisé par le pas de temps, T0 valant 0 et le pas valant 30 secondes.

Troisième étape, on applique HMAC-SHA1 au compteur en utilisant le secret comme clé. Le résultat est un condensé de 20 octets. Quatrième étape, la « troncature dynamique » extrait 4 octets du condensé à une position déterminée par son dernier quartet, convertit ces octets en entier, puis prend le modulo 10 puissance 6 pour obtenir un code à 6 chiffres. Cet enchaînement est entièrement déterministe : deux horloges synchronisées produisent le même code.

Le paramètre crucial pour la fiabilité est la fenêtre de tolérance. Les horloges dérivent. Si le téléphone de l’utilisateur avance de 40 secondes, son code correspond au pas de temps suivant. Pour absorber ce décalage, le serveur vérifie non seulement le pas de temps courant, mais aussi un ou deux pas avant et après. Une fenêtre de plus ou moins 1 pas (90 secondes de tolérance totale) est un bon compromis entre confort et sécurité. Une fenêtre trop large augmente la surface d’attaque par force brute.

Voici les paramètres par défaut interopérables, ceux que comprennent toutes les applications d’authentification grand public. Conservez ces valeurs sauf raison précise, car certaines applications (notamment d’anciennes versions de Google Authenticator) ignorent les paramètres non standard et retombent silencieusement sur SHA-1, 6 chiffres et 30 secondes.

ParamètreValeur par défautRecommandation
Algorithme de hachageSHA-1SHA-1 pour la compatibilité maximale
Nombre de chiffres66 (8 possible mais moins compatible)
Pas de temps (period)30 secondes30 secondes
Longueur du secretVariable160 bits (20 octets)
Fenêtre de tolérance0±1 pas (window=1)
Encodage du secretBase32 (RFC 4648)Base32

Note importante sur SHA-1 : son usage dans HMAC reste sûr pour le TOTP. Les attaques par collision qui ont condamné SHA-1 pour les signatures, comme l’a montré l’attaque SHAttered, ne s’appliquent pas à HMAC, qui repose sur la résistance à la préimage et sur un secret. Vous pouvez donc garder SHA-1 sans crainte pour la 2FA, ce qui maximise la compatibilité.

Étape 1 et 2 : initialiser le projet et installer les dépendances

Créez un dossier de projet, initialisez un package.json et activez les modules ES. Nous utilisons la syntaxe d’import moderne, prise en charge nativement par Node 24. Le champ "type": "module" indique à Node de traiter les fichiers .js comme des modules ES.

mkdir totp-2fa-nodejs && cd totp-2fa-nodejs
npm init -y
npm pkg set type="module"
npm install [email protected] [email protected] [email protected] [email protected] dotenv

Après l’installation, vérifiez que les paquets sont bien présents. La commande ci-dessous liste les dépendances de premier niveau avec leur version exacte. Conservez cette sortie : en cas de bug, comparer les versions installées aux versions attendues est le premier réflexe de dépannage.

npm ls --depth=0

# Sortie attendue :
# [email protected]
# ├── dotenv@latest
# ├── [email protected]
# ├── [email protected]
# ├── [email protected]
# └── [email protected]

Créez ensuite la structure de fichiers du projet. Nous séparons la logique TOTP (le cœur cryptographique) de la couche HTTP (les routes Express) et du stockage. Cette séparation facilite les tests et le remplacement ultérieur du stockage en mémoire par une vraie base de données.

totp-2fa-nodejs/
├── package.json
├── src/
│   ├── totp.js        # génération et vérification TOTP
│   ├── crypto.js      # chiffrement du secret au repos
│   ├── recovery.js    # codes de récupération
│   ├── limites.js     # limitation et verrouillage
│   ├── store.js       # stockage (en mémoire pour la démo)
│   └── server.js      # API Express
└── .env               # clé de chiffrement (jamais commitée)

Étape 3 : générer un secret TOTP robuste

Le secret est la pierre angulaire de la sécurité. Il doit être généré avec un générateur cryptographiquement sûr, jamais avec Math.random(). otplib expose authenticator.generateSecret() qui produit un secret Base32 de longueur appropriée. Par défaut, otplib génère un secret suffisant ; vous pouvez augmenter sa longueur pour atteindre les 160 bits recommandés.

Créez le fichier src/totp.js. Nous configurons explicitement otplib avec les paramètres standard et une fenêtre de tolérance de 1 pas, ce qui autorise un décalage d’horloge de plus ou moins 30 secondes.

// src/totp.js
import { authenticator } from 'otplib';
import QRCode from 'qrcode';

// Configuration conforme aux paramètres interopérables.
authenticator.options = {
  digits: 6,        // code à 6 chiffres
  step: 30,         // pas de temps de 30 secondes
  window: 1,        // tolérance de +/- 1 pas (90 s au total)
  algorithm: 'sha1' // compatibilité maximale
};

const EMETTEUR = 'MonApp';

// Génère un nouveau secret Base32 pour un utilisateur.
export function genererSecret() {
  return authenticator.generateSecret(20); // 20 octets = 160 bits
}

Testez la génération depuis un fichier temporaire ou le REPL Node. Chaque appel produit un secret différent, une chaîne Base32 d’environ 32 caractères composée de lettres majuscules et de chiffres de 2 à 7. Ce secret ne doit jamais transiter en clair en dehors du QR code initial et ne doit jamais être journalisé.

node --input-type=module -e "import('./src/totp.js').then(m => console.log(m.genererSecret()))"

# Sortie (exemple) :
# KRSXG5CTMVRXEZLUKNSWG4TFOQ2GS4ZA

Étape 4 : créer l’URI otpauth:// et le QR code

Pour enrôler un utilisateur, l’application d’authentification doit importer le secret. La méthode standard est l’URI de provisionnement otpauth://, encodée dans un QR code que l’utilisateur scanne. Cette URI suit un format précis défini par la documentation Key URI de Google, repris par tous les clients. Le tableau suivant détaille chaque champ.

ChampExempleDescription
typetotpType d’OTP (totp ou hotp)
labelMonApp:[email protected]Émetteur et identifiant du compte
secretKRSXG5CT…Le secret Base32
issuerMonAppNom du service affiché dans l’application
algorithmSHA1Algorithme de hachage
digits6Longueur du code
period30Pas de temps en secondes

otplib construit cette URI avec authenticator.keyuri(compte, emetteur, secret). Le paquet qrcode la transforme ensuite en image. Ajoutez ces fonctions à src/totp.js. La fonction genererQrCode retourne un data URI PNG en base64, directement utilisable dans l’attribut src d’une balise image côté navigateur.

// src/totp.js (suite)

// Construit l'URI otpauth:// standard.
export function genererUri(compte, secret) {
  return authenticator.keyuri(compte, EMETTEUR, secret);
}

// Transforme l'URI en data URI PNG pour affichage.
export async function genererQrCode(compte, secret) {
  const uri = genererUri(compte, secret);
  return QRCode.toDataURL(uri, { width: 240, margin: 2 });
}

L’URI générée ressemble à otpauth://totp/MonApp:[email protected]?secret=KRSXG5CT...&issuer=MonApp&algorithm=SHA1&digits=6&period=30. Proposez toujours une saisie manuelle du secret en complément du QR code : certains utilisateurs scannent depuis le même appareil que celui qui affiche le code et ne peuvent pas utiliser l’appareil photo.

Étape 5 : vérifier un code TOTP avec tolérance d’horloge

La vérification est le moment critique. otplib fournit authenticator.verify({ token, secret }) qui renvoie un booléen. Grâce à l’option window: 1 définie plus haut, la fonction accepte le code du pas courant, du pas précédent et du pas suivant. Important : otplib effectue une comparaison à temps constant en interne pour éviter les attaques temporelles, vous n’avez pas à gérer cela vous-même.

// src/totp.js (suite)

// Vérifie un code saisi par l'utilisateur.
// Retourne le décalage (delta) du pas validé, ou null si invalide.
export function verifierCode(token, secret) {
  // Nettoyage : retirer les espaces que certaines applications ajoutent.
  const propre = String(token).replace(/\s+/g, '');
  if (!/^\d{6}$/.test(propre)) return null;

  const valide = authenticator.verify({ token: propre, secret });
  if (!valide) return null;

  // checkDelta renvoie le décalage (-1, 0, +1) du pas accepté.
  return authenticator.checkDelta({ token: propre, secret });
}

Pourquoi retourner le delta plutôt qu’un simple booléen ? Parce qu’il sert à la protection contre le rejeu (étape 9). En mémorisant le dernier pas de temps accepté, on peut refuser un code déjà utilisé même s’il est encore valide dans la fenêtre. Voici un test rapide qui génère un code à la volée et le vérifie immédiatement.

// test-verif.js
import { authenticator } from 'otplib';
import { genererSecret, verifierCode } from './src/totp.js';

const secret = genererSecret();
const code = authenticator.generate(secret); // simule l'application mobile
console.log('Code courant :', code);
console.log('Resultat     :', verifierCode(code, secret));   // 0 = pas courant
console.log('Faux code    :', verifierCode('000000', secret)); // null

// Sortie (exemple) :
// Code courant : 482913
// Resultat     : 0
// Faux code    : null

Étape 6 : chiffrer le secret au repos avec AES-256-GCM

Stocker le secret TOTP en clair dans votre base de données est une faute grave. Un attaquant qui accède à la base pourrait générer des codes valides pour tous les comptes. La RFC 6238 demande explicitement de protéger les clés contre tout accès non autorisé. La parade : chiffrer chaque secret avec AES-256-GCM avant le stockage, à l’aide d’une clé maître conservée hors de la base (variable d’environnement, gestionnaire de secrets ou HSM).

Créez src/crypto.js. Nous utilisons le module natif node:crypto, sans dépendance externe. AES-256-GCM fournit à la fois confidentialité et authenticité : le tag d’authentification détecte toute altération du chiffré. Chaque chiffrement utilise un vecteur d’initialisation (IV) aléatoire de 12 octets, jamais réutilisé.

// src/crypto.js
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';

// Clé maître de 32 octets (256 bits), en hexadécimal dans l'environnement.
const CLE = Buffer.from(process.env.TOTP_ENC_KEY || '', 'hex');
if (CLE.length !== 32) {
  throw new Error('TOTP_ENC_KEY doit faire 32 octets (64 caracteres hex).');
}

export function chiffrer(texte) {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', CLE, iv);
  const chiffre = Buffer.concat([cipher.update(texte, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  // Format stocké : iv:tag:chiffre, le tout en base64.
  return [iv, tag, chiffre].map(b => b.toString('base64')).join(':');
}

export function dechiffrer(charge) {
  const [iv, tag, chiffre] = charge.split(':').map(s => Buffer.from(s, 'base64'));
  const decipher = createDecipheriv('aes-256-gcm', CLE, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(chiffre), decipher.final()]).toString('utf8');
}

Générez une clé maître robuste et placez-la dans un fichier .env exclu du dépôt Git. Ne réutilisez jamais cette clé entre environnements de développement et de production. Pour approfondir l’usage du module crypto, consultez notre guide complet sur le HMAC-SHA256 en Node.js, qui partage les mêmes primitives.

# Générer une clé de 256 bits et l'écrire dans .env
node -e "console.log('TOTP_ENC_KEY=' + require('crypto').randomBytes(32).toString('hex'))" > .env

# Vérifier (64 caractères hexadécimaux après le signe =)
cat .env

Étape 7 : générer des codes de récupération

Que se passe-t-il si l’utilisateur perd son téléphone ? Sans plan de secours, il est définitivement bloqué. La solution standard est de fournir, au moment de l’activation, une liste de codes de récupération à usage unique. L’utilisateur les imprime ou les stocke dans son gestionnaire de mots de passe. Chaque code ne fonctionne qu’une fois et contourne le TOTP.

Point essentiel : ne stockez jamais ces codes en clair. Traitez-les comme des mots de passe et hachez-les. Comme ils sont à forte entropie (générés aléatoirement), un hachage SHA-256 suffit ; inutile d’utiliser un algorithme lent comme Argon2, réservé aux mots de passe humains à faible entropie. Créez src/recovery.js.

// src/recovery.js
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';

// Génère 10 codes lisibles de type XXXX-XXXX.
export function genererCodesRecuperation(nombre = 10) {
  const codes = [];
  for (let i = 0; i < nombre; i++) {
    const brut = randomBytes(4).toString('hex').toUpperCase(); // 8 caractères
    codes.push(brut.slice(0, 4) + '-' + brut.slice(4));
  }
  return codes;
}

export function hacherCode(code) {
  const normalise = code.replace(/-/g, '').toUpperCase();
  return createHash('sha256').update(normalise).digest('hex');
}

// Compare en temps constant et renvoie le hachage trouvé ou null.
export function verifierCodeRecuperation(saisie, hashesStockes) {
  const hashSaisie = Buffer.from(hacherCode(saisie), 'hex');
  return hashesStockes.find(h => {
    const ref = Buffer.from(h, 'hex');
    return ref.length === hashSaisie.length && timingSafeEqual(ref, hashSaisie);
  }) ?? null;
}

Lorsqu’un code de récupération est utilisé avec succès, supprimez son hachage de la liste stockée pour empêcher toute réutilisation. Prévenez l’utilisateur quand il ne lui reste plus que deux ou trois codes, afin qu’il en régénère un nouveau lot. Cette logique de secrets à usage unique complète bien notre tutoriel sur le hachage de mots de passe avec bcrypt.

Étape 8 : limiter les tentatives de vérification

Un code à 6 chiffres n’offre qu’un million de combinaisons. Avec la fenêtre de tolérance, un attaquant disposant d’un débit illimité finirait par deviner un code valide. La RFC 4226 insiste sur la nécessité de limiter les tentatives. Nous appliquons une double protection : un verrouillage par compte après plusieurs échecs, et une limitation globale par adresse IP avec express-rate-limit. Créez src/limites.js.

// src/limites.js
import rateLimit from 'express-rate-limit';

// Limiteur dédié à la route de vérification 2FA.
export const limiteurVerif = rateLimit({
  windowMs: 15 * 60 * 1000, // fenêtre de 15 minutes
  max: 5,                   // 5 tentatives par IP et par fenêtre
  standardHeaders: true,
  legacyHeaders: false,
  message: { erreur: 'Trop de tentatives. Réessayez dans 15 minutes.' }
});

const MAX_ECHECS = 5;

export function compteVerrouille(u) {
  return Boolean(u.verrouJusqua) && Date.now() < u.verrouJusqua;
}

export function enregistrerEchec(u) {
  u.echecs = (u.echecs || 0) + 1;
  if (u.echecs >= MAX_ECHECS) {
    u.verrouJusqua = Date.now() + 15 * 60 * 1000;
    u.echecs = 0;
  }
}

La limitation par IP ne suffit pas seule : un attaquant peut faire tourner les adresses. Le compteur d’échecs par compte ferme cette brèche. Après 5 échecs consécutifs, le compte est verrouillé pendant 15 minutes ; notifiez alors l’utilisateur par email. Réinitialisez le compteur à chaque succès. Ce mécanisme, combiné à la fenêtre étroite, rend la force brute en ligne irréaliste.

Étape 9 : protéger contre le rejeu de code

Un code TOTP reste valide pendant 30 à 90 secondes selon la fenêtre. Sans protection, un attaquant qui intercepte un code (par hameçonnage en temps réel, par exemple) peut le rejouer tant qu’il n’a pas expiré. La RFC 6238 recommande d’accepter chaque code au plus une fois. La technique : stocker le dernier pas de temps validé par utilisateur et refuser tout code dont le pas est inférieur ou égal au dernier accepté.

// src/totp.js (suite) : vérification sans rejeu
export function verifierSansRejeu(token, secret, utilisateur) {
  const delta = verifierCode(token, secret);
  if (delta === null) return false;

  // Pas de temps absolu du code accepté.
  const pasActuel = Math.floor(Date.now() / 1000 / 30) + delta;

  if (utilisateur.dernierPas && pasActuel <= utilisateur.dernierPas) {
    return false; // code déjà utilisé : rejet
  }
  utilisateur.dernierPas = pasActuel;
  return true;
}

Cette protection est particulièrement importante face aux kits de hameçonnage modernes, qui relaient les identifiants en temps réel. Elle ne remplace pas une vraie résistance au hameçonnage (que seules les passkeys offrent), mais elle ferme une fenêtre d’attaque concrète. Pour comprendre comment ces attaques fonctionnent, lisez notre dossier sur les attaques par hameçonnage.

Étape 10 à 12 : assembler l’API Express complète

Nous réunissons maintenant tous les modules dans un serveur Express. Trois routes structurent le parcours : /2fa/setup génère le secret et le QR code, /2fa/activate confirme l’activation après un premier code valide, et /2fa/verify valide un code lors de la connexion. En production, ces routes seraient protégées par une session authentifiée (mot de passe déjà vérifié). Pour la démonstration, nous simulons un seul utilisateur.

// src/store.js : stockage en mémoire pour la démo.
// En production, remplacez par PostgreSQL, MySQL ou autre.
const utilisateurs = new Map();

export function getUtilisateur(id) {
  if (!utilisateurs.has(id)) {
    utilisateurs.set(id, {
      id, secretChiffre: null, actif: false,
      codesRecup: [], echecs: 0, dernierPas: 0
    });
  }
  return utilisateurs.get(id);
}

Voici le serveur complet. Notez l’ordre logique : on ne marque jamais la 2FA comme active tant que l’utilisateur n’a pas prouvé qu’il peut générer un code valide. Cela évite de verrouiller un utilisateur qui aurait mal scanné le QR code.

// src/server.js
import 'dotenv/config';
import express from 'express';
import { genererSecret, genererQrCode, verifierSansRejeu, verifierCode } from './totp.js';
import { chiffrer, dechiffrer } from './crypto.js';
import { genererCodesRecuperation, hacherCode } from './recovery.js';
import { getUtilisateur } from './store.js';
import { limiteurVerif, compteVerrouille, enregistrerEchec } from './limites.js';

const app = express();
app.use(express.json());

// Étape 10 : initialiser l'enrôlement.
app.post('/2fa/setup', async (req, res) => {
  const u = getUtilisateur(req.body.userId || 'demo');
  const secret = genererSecret();
  u.secretChiffre = chiffrer(secret); // chiffré au repos
  const qr = await genererQrCode(u.id + '@exemple.fr', secret);
  res.json({ qrCode: qr, secretManuel: secret });
});

// Étape 11 : activer après vérification d'un premier code.
app.post('/2fa/activate', (req, res) => {
  const u = getUtilisateur(req.body.userId || 'demo');
  if (!u.secretChiffre) return res.status(400).json({ erreur: 'Aucun enrolement.' });
  const secret = dechiffrer(u.secretChiffre);
  if (verifierCode(req.body.code, secret) === null) {
    return res.status(400).json({ erreur: 'Code invalide.' });
  }
  const codes = genererCodesRecuperation();
  u.codesRecup = codes.map(hacherCode);
  u.actif = true;
  res.json({ message: '2FA activée.', codesRecuperation: codes });
});

// Étape 12 : vérifier lors de la connexion.
app.post('/2fa/verify', limiteurVerif, (req, res) => {
  const u = getUtilisateur(req.body.userId || 'demo');
  if (!u.actif) return res.status(400).json({ erreur: '2FA non activée.' });
  if (compteVerrouille(u)) return res.status(429).json({ erreur: 'Compte verrouillé.' });
  const secret = dechiffrer(u.secretChiffre);
  if (!verifierSansRejeu(req.body.code, secret, u)) {
    enregistrerEchec(u);
    return res.status(401).json({ erreur: 'Code incorrect.' });
  }
  u.echecs = 0;
  res.json({ message: 'Authentification réussie.' });
});

app.listen(3000, () => console.log('Serveur 2FA sur http://localhost:3000'));

Lancez le serveur avec node src/server.js, puis testez le parcours complet avec curl. La séquence ci-dessous enrôle l’utilisateur, affiche le secret manuel (à entrer dans votre application d’authentification), puis active et vérifie la 2FA. Remplacez 482913 par le code réel affiché dans l’application.

# 1. Enrôlement : récupère le QR code et le secret manuel
curl -s -X POST http://localhost:3000/2fa/setup \
  -H 'Content-Type: application/json' -d '{"userId":"demo"}'

# 2. Activation avec le code affiché dans l'application
curl -s -X POST http://localhost:3000/2fa/activate \
  -H 'Content-Type: application/json' -d '{"userId":"demo","code":"482913"}'

# Sortie attendue :
# {"message":"2FA activée.","codesRecuperation":["A1B2-C3D4", ...]}

# 3. Vérification à la connexion
curl -s -X POST http://localhost:3000/2fa/verify \
  -H 'Content-Type: application/json' -d '{"userId":"demo","code":"715092"}'

# Sortie attendue :
# {"message":"Authentification réussie."}

Le projet complet : récapitulatif des fichiers

Vous disposez désormais d’un système 2FA fonctionnel et modulaire. Chaque responsabilité vit dans son fichier : génération et vérification dans totp.js, chiffrement dans crypto.js, codes de secours dans recovery.js, politique anti-abus dans limites.js, stockage dans store.js et exposition HTTP dans server.js. Cette architecture en couches rend chaque morceau testable isolément et facilite l’audit de sécurité.

Pour passer en production, trois changements suffisent. Remplacez le Map en mémoire de store.js par des requêtes vers votre base de données, en stockant la colonne secretChiffre (jamais le secret en clair), le tableau de hachages de codes de récupération et le champ dernierPas. Liez ensuite chaque route à votre middleware de session existant pour exiger une authentification primaire avant toute opération 2FA. Enfin, déplacez la clé TOTP_ENC_KEY vers un gestionnaire de secrets (Vault, AWS KMS, ou équivalent) plutôt qu’un simple fichier .env.

Le flux utilisateur final se résume ainsi : l’utilisateur active la 2FA depuis ses paramètres (setup puis activate), reçoit ses codes de récupération une seule fois, puis, à chaque connexion ultérieure, saisit le code de son application après son mot de passe. Le serveur déchiffre le secret, vérifie le code avec tolérance, bloque le rejeu, compte les échecs et journalise l’évènement. C’est exactement le comportement attendu d’une 2FA de niveau professionnel.

Cinq erreurs fréquentes à éviter

Ces pièges reviennent dans presque toutes les implémentations TOTP de débutants. Les connaître à l’avance vous fera gagner des heures de débogage.

  • Stocker le secret en clair. C’est l’erreur la plus dangereuse. Un vol de base compromet alors tous les comptes. Chiffrez systématiquement le secret avec AES-256-GCM (étape 6) et gardez la clé maître hors de la base.
  • Oublier la fenêtre de tolérance. Avec window: 0, le moindre décalage d’horloge fait échouer la vérification, ce qui génère un flot de tickets de support. Utilisez window: 1 sans dépasser 2.
  • Activer la 2FA avant validation. Si vous marquez le compte comme protégé dès la génération du secret, un utilisateur qui scanne mal le QR code se retrouve enfermé dehors. Exigez toujours un premier code valide avant d’activer.
  • Ne pas nettoyer la saisie. Les applications affichent souvent le code sous la forme « 482 913 ». Si vous ne retirez pas les espaces, la vérification échoue alors que le code est bon. Filtrez avec une expression régulière.
  • Personnaliser l’algorithme sans raison. Passer en SHA-256 ou à 8 chiffres casse la compatibilité avec d’anciennes versions de Google Authenticator, qui ignorent ces paramètres et calculent un code SHA-1 à 6 chiffres. Restez sur les valeurs par défaut.

Dépannage : huit problèmes courants et leurs solutions

Le tableau suivant recense les symptômes les plus signalés lors de la mise en place du TOTP, avec leur cause probable et la correction. Gardez-le sous la main pendant vos tests.

SymptômeCause probableSolution
Tous les codes sont refusésHorloge serveur désynchroniséeSynchroniser via NTP (chrony ou systemd-timesyncd)
Le code marche une fois sur deuxFenêtre de tolérance à 0Définir window=1
Code refusé avec espacesSaisie non nettoyéeRetirer les espaces avant vérification
QR code illisibleÉmetteur ou label mal encodéEncoder les caractères spéciaux dans le label
L’application affiche le mauvais nomChamp issuer manquantPasser l’émetteur à keyuri()
Erreur TOTP_ENC_KEY lengthClé maître absente ou mauvaise tailleRégénérer 32 octets (64 hex) dans .env
Codes SHA-256 refusés sur mobileApplication ne gère que SHA-1Revenir à SHA-1 pour la compatibilité
Rejet après changement d’heureConfusion heure locale / UTCTOTP utilise l’heure Unix UTC, vérifier le fuseau serveur

La cause numéro un, de loin, est la dérive d’horloge. Le TOTP repose entièrement sur une heure exacte côté serveur. Sur un serveur Linux, vérifiez la synchronisation avec timedatectl status et assurez-vous que la ligne « System clock synchronized » indique « yes ». Sans NTP actif, votre serveur dérivera de quelques secondes par jour et finira par rejeter des codes pourtant corrects.

# Vérifier la synchronisation de l'horloge sur Linux
timedatectl status

# Sortie souhaitée :
#   System clock synchronized: yes
#                 NTP service: active

Astuces avancées pour une 2FA de qualité production

Une fois le socle en place, plusieurs améliorations distinguent une démonstration d’un système prêt pour la production. La première concerne la rotation des clés de chiffrement. Votre clé maître AES doit pouvoir changer sans invalider les secrets existants. Implémentez un identifiant de version de clé stocké à côté de chaque secret chiffré, afin de déchiffrer avec l’ancienne clé puis de rechiffrer avec la nouvelle lors d’une migration progressive.

Deuxième axe, l’enrôlement de plusieurs appareils. Certains utilisateurs veulent enregistrer le même compte sur leur téléphone et leur tablette. Comme le secret est partagé, il suffit de leur présenter le même QR code lors de l’activation. En revanche, ne générez jamais plusieurs secrets distincts pour un même compte sans logique de gestion claire, sous peine de validations incohérentes.

Troisième axe, la journalisation de sécurité. Enregistrez chaque tentative de vérification 2FA (succès et échec) avec l’horodatage et l’adresse IP, sans jamais journaliser le code ni le secret. Ces journaux alimentent la détection d’anomalies : une rafale d’échecs depuis plusieurs pays signale une attaque. Couplez-les à des alertes par email vers l’utilisateur lors d’un nouvel appareil ou d’un verrouillage de compte.

Quatrième axe, la conformité. Les recommandations du NIST SP 800-63B classent le TOTP comme authentificateur « OTP multi-facteur » acceptable au niveau d’assurance AAL2, à condition de protéger le secret et de limiter les tentatives. L’aide-mémoire MFA de l’OWASP détaille les contrôles complémentaires à mettre en place. Enfin, pour les comptes à très haut risque, proposez en plus une clé matérielle FIDO2, plus robuste face au hameçonnage que le TOTP.

TOTP, HOTP et passkeys : que choisir

Le TOTP n’est pas la seule option, et bien choisir dépend de votre contexte. Le HOTP (RFC 4226), basé sur un compteur d’évènements plutôt que sur le temps, sert surtout aux jetons matériels sans horloge interne. Il pose un problème de désynchronisation du compteur si l’utilisateur génère des codes sans les valider, ce qui le rend moins pratique pour le web. Le TOTP, lui, se resynchronise à chaque tic d’horloge.

Les passkeys représentent la génération suivante. Fondées sur la cryptographie à clé publique (WebAuthn et FIDO2), elles résistent au hameçonnage par conception, car la signature est liée au domaine du site. La stratégie gagnante en 2026 consiste à proposer les passkeys comme méthode principale et le TOTP comme repli universel, car tous les utilisateurs n’ont pas encore d’appareil compatible passkey. Pour aller plus loin sur les fondamentaux des signatures à clé publique, lisez notre dossier sur les signatures numériques.

CritèreHOTPTOTPPasskey (FIDO2)
BaseCompteurTempsClé publique
RFC / standardRFC 4226RFC 6238WebAuthn
Résistance au hameçonnageNonNonOui
Risque de désynchronisationÉlevéFaibleAucun
Dépend d’une horlogeNonOuiNon
Compatibilité applicationsLimitéeUniverselleCroissante

Questions fréquentes sur le TOTP en Node.js

Faut-il utiliser otplib ou speakeasy ?

Utilisez otplib (version 13.4.1 en juin 2026). La bibliothèque speakeasy n’a pas reçu de mise à jour depuis sa version 2.0.0 de 2016. otplib est activement maintenue, propose un typage moderne et une API claire pour le TOTP, le HOTP et les URI de provisionnement. Pour un nouveau projet, le choix est sans ambiguïté.

Le TOTP en SHA-1 est-il sûr en 2026 ?

Oui. Les faiblesses de SHA-1 concernent la résistance aux collisions, exploitée pour falsifier des signatures. Le TOTP utilise HMAC-SHA1, qui repose sur un secret et sur la résistance à la préimage, non affectée par ces attaques. Conserver SHA-1 maximise la compatibilité sans compromettre la sécurité de la 2FA.

Combien de codes de récupération générer ?

Dix codes à usage unique constituent une norme confortable. Stockez-en uniquement les hachages SHA-256, supprimez chaque code après usage et invitez l’utilisateur à régénérer un lot quand il en reste moins de trois. Affichez-les une seule fois, au moment de l’activation, jamais ensuite.

Que faire si l’utilisateur perd son téléphone et ses codes ?

Prévoyez une procédure de récupération de compte distincte, par exemple une vérification d’identité renforcée par le support, suivie d’une réinitialisation de la 2FA. Cette procédure doit être plus stricte qu’une simple connexion, car elle constitue la porte de secours et donc une cible privilégiée pour l’ingénierie sociale.

Le TOTP protège-t-il contre le hameçonnage ?

Partiellement. La protection anti-rejeu (étape 9) empêche la réutilisation d’un code intercepté, mais un kit de hameçonnage en temps réel peut relayer le code dans sa fenêtre de validité. Seules les passkeys, liées au domaine, offrent une vraie résistance au hameçonnage. Le TOTP reste néanmoins très supérieur au mot de passe seul.

Peut-on tester le TOTP sans application mobile ?

Oui. La fonction authenticator.generate(secret) d’otplib calcule le code courant côté serveur, ce qui permet de scripter des tests automatisés. Vous pouvez ainsi valider votre flux complet en intégration continue, sans intervention humaine ni appareil physique.

Faut-il chiffrer le secret ou le hacher ?

Il faut le chiffrer, pas le hacher. Le serveur a besoin du secret en clair pour recalculer le code à chaque vérification, donc une opération réversible (chiffrement AES-256-GCM) est obligatoire. Le hachage, irréversible, conviendrait aux mots de passe mais pas aux secrets TOTP.

Quelle version de Node.js utiliser ?

La ligne LTS Node.js 24 (« Krypton ») est le choix recommandé en 2026. Elle apporte un module node:crypto stable, la prise en charge native des modules ES et des correctifs de sécurité à long terme. Évitez les versions impaires (non LTS) en production.

Article publié le 14 juin 2026. Versions logicielles vérifiées sur le registre npm et le calendrier de publication Node.js à cette date. Le code de ce tutoriel est fourni à titre pédagogique : auditez-le et adaptez-le à votre contexte de production avant tout déploiement.