L’autenticazione a due fattori basata su TOTP (Time-based One-Time Password) è oggi lo standard minimo per proteggere un account contro il furto di credenziali. Quando un utente inserisce le sei cifre generate dalla sua app, dimostra di possedere un segreto condiviso che non transita mai in rete dopo l’attivazione. In questo tutorial costruisci, da zero e in 12 step, un sistema 2FA TOTP completo in Node.js: generazione del segreto, QR code di provisioning, verifica con tolleranza temporale, codici di backup, rate limiting e integrazione nel login. Tutto il codice è funzionante e allineato a RFC 6238, alle linee guida OWASP e a NIST SP 800-63B.

Alla fine avrai un progetto Express pronto per la produzione, compatibile con Google Authenticator, Microsoft Authenticator e Authy. Il focus è pratico: ogni step include codice reale, output atteso e gli errori che vediamo più spesso nelle code review. Data di aggiornamento: 14 giugno 2026.

Che cos’è il TOTP e come funziona davvero

Il TOTP è definito in RFC 6238 come estensione dell’algoritmo HOTP descritto in RFC 4226. La differenza è netta: HOTP usa un contatore di eventi che si incrementa a ogni codice, mentre TOTP sostituisce quel contatore con un fattore derivato dal tempo. In pratica il client e il server calcolano lo stesso codice perché condividono due cose: un segreto e l’orologio.

Il meccanismo, ridotto all’osso, funziona così. Si prende l’ora corrente in secondi dall’epoca Unix, la si divide per un intervallo fisso (lo step, di norma 30 secondi) e si ottiene un contatore intero. Questo contatore viene passato, insieme al segreto, a una funzione HMAC. Il digest risultante viene troncato secondo l’algoritmo di dynamic truncation di RFC 4226 fino a ottenere sei cifre decimali. Ogni 30 secondi il contatore cambia, quindi cambia anche il codice. Nessun dato sensibile viaggia in rete dopo l’enrollment: il segreto resta sul telefono dell’utente e nel database del server.

L’algoritmo predefinito dell’ecosistema TOTP è HMAC-SHA1, ereditato da HOTP. Non è una scelta debole come può sembrare: SHA-1 qui è usato all’interno di un HMAC con chiave segreta, contesto in cui le collisioni note di SHA-1 non hanno alcun impatto pratico. RFC 6238 prevede comunque anche HMAC-SHA-256 e HMAC-SHA-512, ma attenzione: la maggior parte delle app di autenticazione assume SHA-1, e cambiare algoritmo senza che il client lo supporti produce codici che non combaciano mai. È la prima causa di “il codice è sempre sbagliato” che incontriamo.

Il segreto condiviso è quasi sempre codificato in Base32, perché si presta a essere scansionato in un QR code e digitato a mano senza ambiguità (niente 0/O o 1/l). La lunghezza raccomandata è di 160 bit, valore che si allinea naturalmente all’output da 160 bit di HMAC-SHA1 e che NIST considera adeguato per un autenticatore di questo tipo. Da questi pochi parametri (segreto, step, cifre, algoritmo) deriva tutto il comportamento del sistema: vale la pena fissarli in modo esplicito invece di affidarsi ai default impliciti.

TOTP vs HOTP vs passkey: quale scegliere nel 2026

Prima di scrivere codice conviene capire dove si colloca il TOTP nel panorama del 2026. HOTP, basato su contatore, soffre di problemi di desincronizzazione: se l’utente genera codici senza usarli, contatore client e server divergono e serve una finestra di recupero. Per questo TOTP, ancorato al tempo, ha vinto come standard di fatto per le app di autenticazione.

Il limite vero del TOTP è un altro, e va detto con chiarezza fin da subito: non è phishing-resistant. Un attaccante che gestisce un proxy Adversary-in-the-Middle (AiTM) può intercettare in tempo reale sia la password sia il codice a sei cifre e replicarli verso il sito legittimo entro la finestra di validità. Le analisi di sicurezza del 2024-2025 hanno mostrato un aumento netto di questi attacchi con kit di phishing come Evilginx. Per questo, dove possibile, le passkey FIDO2/WebAuthn restano superiori perché legano l’autenticazione all’origine del sito.

Detto questo, il TOTP rimane lo strumento giusto in moltissimi contesti: non richiede hardware dedicato, funziona offline, è gratuito, è compatibile con tutte le app diffuse e alza enormemente il costo di un attacco rispetto alla sola password. La strategia sensata nel 2026 è offrire le passkey come opzione primaria e il TOTP come secondo fattore universale e fallback. Il sistema che costruiamo qui è esattamente questo secondo livello.

CaratteristicaTOTP (RFC 6238)HOTP (RFC 4226)Passkey (FIDO2)
Fattore mobileTempo (30 s)Contatore eventiChiave crittografica
Phishing-resistantNoNo
Richiede hardwareNoNoNo (usa il dispositivo)
Funziona offline
DesincronizzazioneSolo per drift orarioFrequenteAssente
App compatibiliTutteLimitateBrowser e OS moderni

Prerequisiti: versioni, librerie e ambiente

Per seguire il tutorial servono competenze base di JavaScript e Node.js e un terminale. Useremo otplib come libreria TOTP perché è attivamente mantenuta e implementa correttamente RFC 6238 e RFC 4226. Una nota sulla scelta: speakeasy, a lungo la libreria più popolare, è ferma alla versione 2.0.0 da anni e non riceve aggiornamenti significativi; per un progetto nuovo nel 2026 consigliamo otplib. La tabella riporta le versioni verificate al 14 giugno 2026.

ComponenteVersioneRuolo nel progetto
Node.js22 LTS o superioreRuntime, modulo crypto nativo
otplib13.4.1Generazione e verifica TOTP
qrcode1.5.4QR code dall’URI otpauth://
express5.2.1Server e rotte di login/2FA
express-rate-limitultima versioneThrottling sulla verifica OTP
better-sqlite3ultima versionePersistenza dei segreti (demo)

Verifica subito la versione di Node, perché otplib 13 richiede un runtime moderno e il modulo crypto aggiornato per la cifratura del segreto a riposo che vedremo nello Step 5.

node --version
# atteso: v22.x.x o superiore

npm --version
# atteso: 10.x o superiore

Step 1-2: Inizializzare il progetto e installare le dipendenze

Crea la cartella del progetto e inizializza npm. Usiamo i moduli ES ("type": "module") perché sono ormai lo standard. Negli esempi alterniamo la sintassi import ES e, dove più chiaro, l’equivalente CommonJS.

# Step 1: progetto
mkdir totp-2fa-node && cd totp-2fa-node
npm init -y
npm pkg set type=module

# Step 2: dipendenze
npm install [email protected] [email protected] [email protected] \
  express-rate-limit better-sqlite3

Output atteso al termine dell’installazione: npm elenca i pacchetti aggiunti senza vulnerabilità critiche. Se compaiono warning su better-sqlite3 relativi alla compilazione nativa, verifica di avere i build tools del sistema operativo (su Debian/Ubuntu: build-essential e python3). La struttura minima che andremo a popolare è questa:

totp-2fa-node/
├── package.json
├── totp.js         # logica TOTP: segreto, URI, verifica
├── crypto-store.js # cifratura del segreto a riposo
├── backup.js       # codici di recupero
├── db.js           # persistenza demo
└── server.js       # app Express completa

Step 3: Generare il segreto condiviso TOTP

Il cuore del 2FA è il segreto. Con otplib lo generi in una riga: authenticator.generateSecret() restituisce una stringa Base32 pronta per il provisioning. Per impostazione predefinita otplib produce un segreto di lunghezza adeguata; passando 20 byte ottieni i 160 bit raccomandati da RFC 4226. Configuriamo anche le opzioni globali in modo esplicito, così il comportamento è prevedibile e documentato.

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

// Parametri espliciti, allineati a RFC 6238 e alle app diffuse
authenticator.options = {
  step: 30,          // intervallo in secondi
  digits: 6,         // lunghezza del codice
  algorithm: 'sha1', // default compatibile con Google Authenticator
  window: 1,         // tolleranza: +/- 1 step (vedi Step 7)
};

export function generaSegreto() {
  // 20 byte = 160 bit di entropia, codificati in Base32
  return authenticator.generateSecret(20);
}

console.log(generaSegreto());
// output esempio: KZXW6YTBOI======

Una regola d’oro: genera un segreto nuovo per ogni utente e per ogni nuovo enrollment. Non riutilizzare mai lo stesso segreto tra account diversi e non derivarlo dalla password. Il segreto è materiale crittografico ad alta entropia e va trattato come tale. Nel prossimo step lo trasformiamo in qualcosa che l’app dell’utente possa importare con una scansione.

Step 4: Creare l’URI otpauth:// e il QR code di provisioning

Le app di autenticazione importano il segreto tramite un URI standard nel formato otpauth://totp/Emittente:account?secret=...&issuer=.... L’etichetta contiene l’emittente (il nome del tuo servizio) e l’identificativo dell’utente; i parametri trasportano il segreto Base32 e ripetono l’issuer. otplib costruisce questo URI con authenticator.keyuri(). Poi qrcode lo trasforma in un’immagine scansionabile.

// totp.js (continua)
import QRCode from 'qrcode';

export function creaOtpauthUri(account, segreto, emittente = 'ShatteredApp') {
  return authenticator.keyuri(account, emittente, segreto);
}

export async function creaQrDataUrl(otpauthUri) {
  // Data URL PNG da incorporare in un tag img nella pagina di setup
  return QRCode.toDataURL(otpauthUri, { margin: 1, width: 240 });
}

// Esempio d'uso
const segreto = generaSegreto();
const uri = creaOtpauthUri('[email protected]', segreto);
console.log(uri);
// otpauth://totp/ShatteredApp:[email protected]?secret=KZXW6YTB...&issuer=ShatteredApp

L’issuer non è cosmetico: è ciò che l’utente vede nell’app accanto al codice. Impostarlo correttamente evita la confusione tra account multipli e riduce il rischio che l’utente cancelli la voce sbagliata. Mostra all’utente, nella stessa schermata, anche il segreto in chiaro come fallback per chi non può scansionare il QR (per esempio chi usa un gestore di password desktop). Per approfondire come le diverse app gestiscono questi URI, vedi il nostro confronto su Google Authenticator, Microsoft Authenticator e Authy.

Step 5: Cifrare il segreto a riposo nel database

Qui si gioca la sicurezza reale del sistema. Se un attaccante esfiltra il database e i segreti TOTP sono in chiaro, può rigenerare i codici di chiunque e il secondo fattore evapora. Il segreto non va hashato (deve restare recuperabile per il calcolo), quindi va cifrato a riposo con una chiave che vive fuori dal database, in una variabile d’ambiente o in un secrets manager. Usiamo AES-256-GCM dal modulo crypto nativo di Node, che fornisce confidenzialità e autenticazione del dato.

// crypto-store.js
import crypto from 'node:crypto';

// 32 byte (256 bit) in esadecimale, da variabile d'ambiente
const KEY = Buffer.from(process.env.TOTP_ENC_KEY, 'hex');

export function cifra(testo) {
  const iv = crypto.randomBytes(12); // 96 bit, raccomandato per GCM
  const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
  const enc = Buffer.concat([cipher.update(testo, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  // formato: iv:tag:ciphertext (tutto in base64)
  return [iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join(':');
}

export function decifra(pacchetto) {
  const [ivB64, tagB64, dataB64] = pacchetto.split(':');
  const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivB64, 'base64'));
  decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
  return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
}

Genera la chiave una sola volta con openssl rand -hex 32 e conservala fuori dal repository. Per i fondamenti della cifratura simmetrica in Node trovi il dettaglio nella nostra guida alla crittografia end-to-end in Node.js. Da qui in poi, ogni volta che salvi un segreto chiami cifra(segreto) e quando devi verificare un codice chiami decifra().

Step 6: Verificare il codice TOTP inserito dall’utente

La verifica è il momento in cui il server ricalcola il codice atteso e lo confronta con quello digitato. Con otplib usi authenticator.verify({ token, secret }), che restituisce un booleano. La libreria gestisce internamente il confronto e la finestra di tolleranza configurata nello Step 3. Non implementare mai il confronto a mano con === su stringhe: otplib applica già la logica corretta.

// totp.js (continua)
export function verificaToken(token, segreto) {
  // token: le 6 cifre digitate; segreto: stringa Base32 decifrata
  try {
    return authenticator.verify({ token: String(token).trim(), secret: segreto });
  } catch {
    return false; // token malformato (non numerico, lunghezza errata)
  }
}

// Test rapido
const s = generaSegreto();
const codiceCorrente = authenticator.generate(s);
console.log(verificaToken(codiceCorrente, s)); // true
console.log(verificaToken('000000', s));        // quasi certamente false

Nota l’uso di trim() e String(): gli utenti incollano spesso codici con spazi o li passano come numeri che perdono lo zero iniziale. Un codice come 012345 diventa 12345 se trattato come intero, e la verifica fallisce. Normalizza sempre l’input prima di verificarlo. Questo è uno degli errori più frequenti e più frustranti da diagnosticare.

Step 7: Gestire il clock skew e la deriva temporale

TOTP dipende dal fatto che orologio del client e del server siano allineati. In pratica non lo sono mai perfettamente: il telefono dell’utente può essere avanti o indietro di qualche secondo. Per assorbire questa deriva si accetta anche il codice degli step adiacenti, configurando il parametro window. Con window: 1 il server accetta il codice corrente più quello precedente e quello successivo, coprendo circa 90 secondi totali.

NIST SP 800-63B è esplicito su questo punto: la durata accettata di un OTP deve essere basata sulla deriva attesa dell’orologio dell’autenticatore, in entrambe le direzioni. Tradotto in pratica: tieni la finestra stretta. Una finestra ampia (per esempio window: 5, che coprirebbe oltre cinque minuti) sembra comoda ma moltiplica i codici validi contemporaneamente, riducendo l’entropia effettiva e facilitando il brute force.

Valore windowCodici accettatiCopertura temporaleRaccomandazione
0Solo corrente~30 sTroppo rigido per il mondo reale
1Precedente, corrente, successivo~90 sConsigliato (default sensato)
25 codici~150 sSolo con utenti dagli orologi notoriamente instabili
5 o più11+ codici5,5+ minutiSconsigliato: indebolisce la sicurezza

Assicurati che il server sincronizzi l’ora via NTP (systemd-timesyncd o chrony). Un server con orologio sbagliato fa fallire ogni verifica, anche con il codice giusto, e produce ticket di supporto impossibili da capire. È la causa numero uno dei problemi in produzione e la prima cosa da controllare quando “tutti i codici sono errati”.

Step 8: Generare e gestire i codici di backup

Cosa succede se l’utente perde il telefono? Senza una via di recupero, perde l’accesso all’account. I codici di backup (recovery codes) risolvono il problema: una serie di codici monouso da conservare in luogo sicuro, ognuno valido una sola volta. Vanno trattati come password: non salvarli mai in chiaro nel database, ma hashati. Qui usiamo lo scrypt nativo di Node.

// backup.js
import crypto from 'node:crypto';

export function generaCodiciBackup(quanti = 10) {
  const codici = [];
  for (let i = 0; i < quanti; i++) {
    // 5 byte -> 10 cifre esadecimali, leggibili e robuste
    codici.push(crypto.randomBytes(5).toString('hex'));
  }
  return codici;
}

export function hashCodice(codice) {
  const salt = crypto.randomBytes(16);
  const dk = crypto.scryptSync(codice, salt, 32);
  return salt.toString('hex') + ':' + dk.toString('hex');
}

export function verificaCodiceBackup(codice, hashSalvato) {
  const [saltHex, dkHex] = hashSalvato.split(':');
  const dk = crypto.scryptSync(codice, Buffer.from(saltHex, 'hex'), 32);
  // confronto a tempo costante per evitare timing attack
  return crypto.timingSafeEqual(dk, Buffer.from(dkHex, 'hex'));
}

Mostra i codici di backup all’utente una sola volta, subito dopo l’attivazione del 2FA, e invitalo a stamparli o salvarli nel gestore di password. Quando un codice viene usato, rimuovilo o marcalo come consumato. L’uso di crypto.timingSafeEqual non è un dettaglio: il confronto a tempo costante impedisce a un attaccante di dedurre il codice carattere per carattere misurando i tempi di risposta. Per le basi dell’hashing sicuro vedi la nostra guida su hashing delle password con bcrypt.

Step 9: Rate limiting sulla verifica OTP contro il brute force

Un codice a sei cifre ha un milione di combinazioni. Sembrano tante, ma senza limiti un attaccante con la password già rubata può tentarle tutte in poco tempo, soprattutto con una finestra window generosa che tiene validi più codici insieme. Il rate limiting sull’endpoint di verifica è obbligatorio, non opzionale. OWASP lo elenca tra i controlli fondamentali per l’autenticazione multifattore.

// nel server.js
import rateLimit from 'express-rate-limit';

export const limiteOtp = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minuti
  max: 5,                   // max 5 tentativi per IP+utente nella finestra
  standardHeaders: true,
  legacyHeaders: false,
  message: { errore: 'Troppi tentativi. Riprova tra qualche minuto.' },
  keyGenerator: (req) => `${req.ip}:${req.body?.userId ?? 'anon'}`,
});

Cinque tentativi ogni 15 minuti sono un punto di partenza ragionevole. Affianca al rate limiting un contatore persistente di fallimenti per utente: dopo N fallimenti consecutivi, blocca temporaneamente il 2FA e notifica l’utente via email, perché tentativi ripetuti sono spesso il segnale di un attacco in corso con credenziali già compromesse. Lo stesso principio difensivo lo applichiamo nella nostra guida alla protezione CSRF in Node.js.

Step 10: Integrare il 2FA nel flusso di login con Express

Ora colleghiamo i pezzi. Il flusso corretto è in due fasi: prima l’utente supera la verifica della password (primo fattore), poi viene messo in uno stato “in attesa di 2FA” e solo dopo aver fornito un TOTP valido riceve la sessione autenticata completa. Non emettere mai una sessione piena prima del secondo fattore.

// server.js (estratto delle rotte 2FA)
import express from 'express';
import { verificaToken } from './totp.js';
import { decifra } from './crypto-store.js';
import { verificaCodiceBackup } from './backup.js';
import { getUtente, consumaBackup } from './db.js';

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

// Fase 2: l'utente ha gia superato la password ed ha stato "pending2fa"
app.post('/2fa/verifica', limiteOtp, (req, res) => {
  const { userId, token } = req.body;
  const u = getUtente(userId);
  if (!u || !u.totpAttivo) return res.status(400).json({ errore: 'Setup non valido' });

  const segreto = decifra(u.segretoCifrato);
  if (verificaToken(token, segreto)) {
    req.session = { userId, mfa: true }; // sessione piena
    return res.json({ ok: true });
  }

  // Fallback: prova come codice di backup
  for (const h of u.codiciBackup) {
    if (verificaCodiceBackup(token, h)) {
      consumaBackup(userId, h);
      req.session = { userId, mfa: true };
      return res.json({ ok: true, backupUsato: true });
    }
  }
  return res.status(401).json({ errore: 'Codice non valido' });
});

Questo pattern di “sessione a due livelli” si integra bene con i token applicativi: se usi i JWT per le API, emetti il token finale solo dopo la verifica del secondo fattore. Approfondisci nella nostra guida all’autenticazione JWT in Node.js.

Step 11: Disattivare, rigenerare e gestire il ciclo di vita

Un sistema 2FA non finisce all’attivazione. Devi gestire la disattivazione (che richiede sempre una conferma con un codice TOTP valido o la password, mai a clic libero), la rigenerazione del segreto se l’utente cambia dispositivo, e la rigenerazione dei codici di backup quando si esauriscono. Ogni operazione sensibile va registrata in un log di audit con timestamp e IP.

// server.js (gestione ciclo di vita)
app.post('/2fa/disattiva', limiteOtp, (req, res) => {
  const { userId, token } = req.body;
  const u = getUtente(userId);
  const segreto = decifra(u.segretoCifrato);
  if (!verificaToken(token, segreto)) {
    return res.status(401).json({ errore: 'Conferma con un codice valido' });
  }
  disattiva2fa(userId);          // azzera segreto e codici di backup
  logAudit(userId, '2fa_off', req.ip);
  res.json({ ok: true });
});

Punto critico spesso trascurato: durante l’enrollment, attiva il 2FA solo dopo che l’utente ha confermato un primo codice valido. Se imposti totpAttivo = true appena generi il segreto, senza verificare che l’utente lo abbia davvero importato, rischi di bloccarlo fuori dal proprio account perché ha scansionato male il QR. La sequenza corretta è: genera segreto, mostra QR, l’utente inserisce un codice, verifichi, e solo se è valido attivi.

Step 12: Il progetto completo funzionante

Mettiamo insieme i moduli in un server.js avviabile. Questa versione usa un archivio in memoria per chiarezza; in produzione sostituisci con better-sqlite3 o il tuo database, mantenendo la cifratura del segreto a riposo dello Step 5.

// server.js (versione demo eseguibile)
import express from 'express';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { cifra, decifra } from './crypto-store.js';
import { verificaToken } from './totp.js';
import { generaCodiciBackup, hashCodice } from './backup.js';

const app = express();
app.use(express.json());
const utenti = new Map(); // demo: { segretoCifrato, totpAttivo, codiciBackup }

// 1) Avvio enrollment: genera segreto e QR
app.post('/2fa/setup', async (req, res) => {
  const { userId, email } = req.body;
  const segreto = authenticator.generateSecret(20);
  const uri = authenticator.keyuri(email, 'ShatteredApp', segreto);
  const qr = await QRCode.toDataURL(uri);
  utenti.set(userId, { segretoCifrato: cifra(segreto), totpAttivo: false, codiciBackup: [] });
  res.json({ qr, segreto }); // segreto in chiaro solo qui, come fallback manuale
});

// 2) Conferma enrollment: l'utente prova un codice
app.post('/2fa/conferma', (req, res) => {
  const { userId, token } = req.body;
  const u = utenti.get(userId);
  const segreto = decifra(u.segretoCifrato);
  if (!verificaToken(token, segreto)) return res.status(401).json({ errore: 'Codice errato' });
  const codici = generaCodiciBackup(10);
  u.codiciBackup = codici.map(hashCodice);
  u.totpAttivo = true;
  res.json({ ok: true, codiciBackup: codici }); // mostrati una sola volta
});

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

Avvia con TOTP_ENC_KEY=$(openssl rand -hex 32) node server.js. Chiama POST /2fa/setup, scansiona il QR con la tua app, poi conferma con POST /2fa/conferma. Output atteso alla conferma: { "ok": true, "codiciBackup": [ ... ] }. Hai un sistema 2FA TOTP funzionante end-to-end.

Errori comuni da evitare con il TOTP

Questi sono i passi falsi che vediamo più spesso e che, presi insieme, spiegano la quasi totalità dei sistemi 2FA difettosi in produzione.

  • Salvare il segreto in chiaro nel database. Cifralo sempre a riposo (Step 5): un dump del DB non deve compromettere il secondo fattore.
  • Trattare il codice come numero intero, perdendo gli zero iniziali. Normalizza l’input come stringa con trim() prima della verifica.
  • Finestra di tolleranza troppo ampia. Un window alto sembra comodo ma indebolisce la sicurezza moltiplicando i codici validi.
  • Nessun rate limiting sull’endpoint di verifica. Senza throttling, un milione di combinazioni è alla portata di uno script.
  • Attivare il 2FA senza conferma. Se non verifichi un primo codice, rischi di bloccare fuori l’utente che ha sbagliato la scansione.
  • Dimenticare i codici di backup. Senza recovery, ogni telefono perso è un account perso e una richiesta di supporto.
  • Cambiare algoritmo in sha256 assumendo che le app lo seguano. La maggior parte usa SHA-1: il codice non combacerà mai.
  • Server con orologio non sincronizzato. Senza NTP, TOTP fallisce anche con il codice corretto.

Risoluzione dei problemi: 8 casi frequenti

Quando il 2FA “non funziona”, il problema è quasi sempre uno di questi otto. La tabella riporta sintomo, causa probabile e soluzione.

SintomoCausa probabileSoluzione
Il codice è sempre rifiutatoOrologio del server desincronizzatoAttiva NTP (chrony/systemd-timesyncd) e riprova
Funziona a intermittenzaDrift orario del telefonoImposta window: 1 e invita a sincronizzare l’ora del dispositivo
Codice che inizia per 0 fallisceCodice trattato come interoGestisci il token come stringa, niente parseInt
QR non scansionabileURI otpauth:// malformatoVerifica issuer e codifica dei parametri con keyuri()
Verifica sempre false nei testAlgoritmo impostato su sha256/sha512Torna a algorithm: 'sha1' salvo client dedicato
App mostra “account duplicato”Enrollment ripetuto senza puliziaRigenera segreto e fai cancellare la voce vecchia
Errore di decifratura del segretoTOTP_ENC_KEY cambiata o assenteUsa la stessa chiave usata per cifrare; ruotala con re-cifratura
Troppi “Too Many Requests”Rate limit troppo aggressivoCalibra max e windowMs; separa la chiave per utente

Tecniche avanzate per il TOTP in produzione

Una volta che il flusso base funziona, alcune migliorie distinguono un sistema giocattolo da uno robusto. Rotazione della chiave di cifratura: la TOTP_ENC_KEY va ruotata periodicamente; implementa una procedura che decifra con la vecchia chiave e ri-cifra con la nuova, mantenendo un identificatore di versione della chiave nel pacchetto cifrato.

Prevenzione del replay: RFC 6238 raccomanda di non accettare lo stesso codice due volte nella stessa finestra. Salva l’ultimo contatore di step verificato per utente e rifiuta un codice il cui step è già stato consumato. Questo blocca un attaccante che intercetta un codice e prova a riusarlo nei secondi successivi.

Notifiche di sicurezza: invia un’email all’utente a ogni attivazione, disattivazione o uso di un codice di backup. Un utente che riceve “il tuo 2FA è stato disattivato” senza averlo fatto ha un segnale di compromissione immediato. Step-up authentication: richiedi un nuovo TOTP per le operazioni ad alto rischio (cambio email, esportazione dati) anche dentro una sessione già autenticata.

Infine, monitora le metriche: tasso di fallimento delle verifiche, percentuale di utenti con 2FA attivo, uso dei codici di backup. Un picco di fallimenti su molti account contemporaneamente è spesso il primo sintomo visibile di una campagna di credential stuffing in corso, un tema che approfondiamo nella sezione sicurezza.

Sicurezza del TOTP: phishing, AiTM e i limiti reali

Va detto senza giri di parole: il TOTP alza moltissimo l’asticella rispetto alla sola password, ma non è invincibile. Il vettore di attacco rilevante nel 2026 è il phishing in tempo reale tramite proxy Adversary-in-the-Middle. L’utente viene attirato su un sito clone che inoltra in diretta credenziali e codice al sito vero. Poiché il codice è valido per circa 30-90 secondi, l’attaccante ha una finestra sufficiente per dirottare la sessione.

Gli OTP basati sul tempo restano vulnerabili agli attacchi di phishing in tempo reale: un avversario che intercetta e ritrasmette il codice entro la sua finestra di validità può aggirare il secondo fattore. Per questo le linee guida raccomandano gli autenticatori legati all’origine, come quelli FIDO2.

Sintesi delle indicazioni di OWASP e NIST SP 800-63B

Le contromisure pratiche: lega la sessione a fattori contestuali (impronta del dispositivo, IP, geolocalizzazione) e segnala accessi anomali; riduci la finestra di validità al minimo praticabile; e, soprattutto, offri le passkey come metodo primario per gli utenti che possono usarle. Il TOTP rimane il fallback universale ottimo, ma comunicare ai tuoi utenti che esistono alternative più robuste è parte di una strategia di autenticazione matura. Per il quadro generale sulla difesa delle credenziali, vedi la nostra guida alla sicurezza delle password.

Un’ultima nota sull’entropia: il segreto TOTP ha entropia fissa e il codice ha lunghezza fissa. Non aumentare artificiosamente le cifre oltre 6-8 pensando di renderlo più sicuro; oltre un certo punto peggiori solo l’usabilità senza guadagni reali, perché la vera difesa contro il brute force è il rate limiting, non la lunghezza del codice.

Domande frequenti sul TOTP in Node.js

Devo usare otplib o speakeasy?

Per un progetto nuovo nel 2026 consigliamo otplib (versione 13.4.1), perché è attivamente mantenuto e implementa correttamente RFC 6238 e RFC 4226. speakeasy è fermo alla 2.0.0 da anni e non riceve aggiornamenti significativi: funziona ancora, ma non lo sceglieremmo per nuovo codice. Entrambi producono codici interoperabili con le app standard.

Il TOTP funziona senza connessione internet?

Sì. Dopo l’enrollment, l’app dell’utente calcola i codici localmente usando solo il segreto e l’orologio del dispositivo. Non serve connessione né dal lato client né per la generazione. Il server, invece, deve essere raggiungibile e con l’ora sincronizzata per verificare il codice.

Perché il codice viene rifiutato anche se sembra giusto?

Nel 90% dei casi è un problema di orologio: server desincronizzato o telefono con ora errata. Attiva NTP sul server e imposta window: 1 per assorbire piccole derive. Le altre cause frequenti sono il codice trattato come intero (perde lo zero iniziale) e un algoritmo impostato su SHA-256 mentre l’app usa SHA-1.

Quanti codici di backup devo generare?

Dieci codici monouso sono lo standard adottato dalla maggior parte dei servizi. Conservali hashati nel database, mostrali all’utente una sola volta e consenti la rigenerazione (che invalida i vecchi) dalle impostazioni di sicurezza. Avvisa l’utente quando ne restano pochi.

Il TOTP è sicuro contro il phishing?

No, e va comunicato con onestà. Il TOTP protegge bene contro password rubate e database compromessi, ma un attacco Adversary-in-the-Middle in tempo reale può intercettare e replicare il codice entro la sua finestra di validità. Per la massima resistenza al phishing servono le passkey FIDO2/WebAuthn. Il TOTP resta un ottimo secondo fattore universale e un fallback solido.

Posso usare lo stesso segreto su più dispositivi?

Tecnicamente sì: scansionando lo stesso QR su più telefoni, tutti genereranno gli stessi codici. Alcuni utenti lo fanno volutamente come backup. Dal lato server non cambia nulla. Sconsiglia però di condividere il segreto tra persone diverse: vanifica la funzione di possesso individuale del secondo fattore.

Quale lunghezza del codice scelgo, 6 o 8 cifre?

Sei cifre sono lo standard e la scelta giusta per la quasi totalità dei casi: è ciò che le app mostrano per impostazione predefinita. Otto cifre sono ammesse da RFC 6238 e aggiungono entropia, ma molte app non le supportano e l’usabilità peggiora. La difesa efficace contro il brute force resta il rate limiting, non le cifre extra.

Come gestisco la rotazione della chiave di cifratura del segreto?

Aggiungi un identificatore di versione al pacchetto cifrato (per esempio v2:iv:tag:ciphertext). Quando ruoti la chiave, una procedura batch decifra con la vecchia chiave e ri-cifra con la nuova, aggiornando la versione. Così puoi ruotare senza disattivare il 2FA degli utenti e senza tempi di inattività.

Fonti e approfondimenti

Ultimo aggiornamento: 14 giugno 2026. Le versioni dei pacchetti sono verificate alla data di pubblicazione; controlla sempre l’ultima release su npm prima di mettere in produzione.