Come funziona Google Authenticator
Un corso con il codice davanti. Alla fine avrai due pagine funzionanti — registrazione e login a due fattori — e saprai esattamente cosa succede dietro quelle sei cifre che cambiano ogni trenta secondi.
Quel numero Finestra T è il cuore di tutto: è ciò che il tuo telefono e il server calcolano nello stesso istante senza scambiarsi nulla.
00Il problema che risolve
Una password è un segreto che viaggia. Ogni volta che accedi la mandi al server: se qualcuno la intercetta, la ruba da un database o te la fa scrivere su un sito falso, l'ha per sempre. Non ha scadenza.
Il secondo fattore serve a rompere proprio questa proprietà. L'obiettivo è dimostrare "possiedo il telefono" con qualcosa che:
- scade in fretta — intercettarlo dopo un minuto non serve a niente;
- non richiede rete sul telefono — funziona in aereo, senza SIM, senza dati;
- non richiede terze parti — niente SMS, niente costi, niente SIM swap.
TOTP fa esattamente questo, ed è definito da due specifiche pubbliche: RFC 4226 (HOTP, il meccanismo di base) e RFC 6238 (TOTP, la variante basata sul tempo). "Google Authenticator" è solo un'app che implementa quello standard: Aegis, 1Password, Bitwarden, Authy fanno la stessa identica cosa e sono intercambiabili.
01L'idea in tre righe
(segreto + numero di finestra) nella stessa funzione matematica.
Se le sei cifre coincidono, chi le ha digitate possiede il segreto.
Nota cosa non succede: il codice non viene mai inviato dal server al telefono. Non c'è niente da intercettare in transito, perché non transita. Viene calcolato in due posti diversi, in parallelo, a partire dagli stessi due ingredienti.
I due fattori del login sono di natura diversa, ed è questo che li rende utili insieme:
| Primo fattore | Secondo fattore | |
|---|---|---|
| Cos'è | qualcosa che sai | qualcosa che possiedi |
| Nella demo | la password | il codice dell'app |
| Viaggia | a ogni login | una volta sola, nel QR |
| Scade | mai | ogni 30 secondi |
| Sul server | hash scrypt irreversibile | segreto in chiaro (serve per ricalcolare) |
02Passo 1 — La password, primo fattore
Il TOTP da solo non è autenticazione a due fattori: è autenticazione a un fattore fatta diversamente. Serve anche la password — e va gestita bene, altrimenti il secondo fattore protegge una porta già aperta.
Una password non si salva mai in chiaro, e nemmeno con un hash veloce come SHA-256: una GPU
ne prova miliardi al secondo. Serve una funzione deliberatamente lenta e affamata di
memoria. Qui si usa scrypt, già dentro Node.
const crypto = require('crypto');
function hashPassword(plain) {
const salt = crypto.randomBytes(16); // diverso per ogni utente
const derived = crypto.scryptSync(String(plain), salt, 64);
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`;
}
Il salt casuale fa sì che due utenti con la stessa password producano hash diversi: le rainbow table precalcolate diventano inutili. Salt e hash stanno nella stessa stringa, perché per verificare servono entrambi.
function verifyPassword(plain, stored) {
const [algo, saltHex, hashHex] = String(stored).split('$');
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const derived = crypto.scryptSync(String(plain), salt, expected.length);
return crypto.timingSafeEqual(derived, expected); // confronto a tempo costante
}
argon2id è oggi la raccomandazione preferita da OWASP (richiede il pacchetto
argon2); bcrypt resta diffusissimo e valido. scrypt è
la scelta di questa demo perché non aggiunge dipendenze.
03Passo 2 — Il segreto TOTP
È una sequenza di byte casuali. RFC 4226 §4 dice: minimo 128 bit, raccomandati 160,
cioè 20 byte, da un generatore crittografico — non da Math.random().
const crypto = require('crypto');
const SECRET_BYTES = 20; // 160 bit — raccomandazione RFC 4226 §4
function generateSecret() {
return base32Encode(crypto.randomBytes(SECRET_BYTES));
}
Perché Base32 e non esadecimale o Base64
Il segreto deve poter essere digitato a mano quando la fotocamera non funziona
o l'app è su un altro dispositivo. L'alfabeto Base32 (RFC 4648) è A–Z più
2–7: niente minuscole, niente 0, 1, 8. Sparisce
ogni ambiguità fra O e 0, fra l e 1. Base64 avrebbe metà di questi problemi e in più
+ e /, impossibili da dettare al telefono.
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buffer) {
let bits = 0; // quanti bit "in attesa" ho accumulato
let value = 0; // il magazzino di bit
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte; // aggiungo 8 bit al magazzino
bits += 8;
while (bits >= 5) { // ogni 5 bit disponibili emetto un carattere
output += B32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += B32_ALPHABET[(value << (5 - bits)) & 31];
return output;
}
Ogni carattere Base32 trasporta 5 bit. 20 byte × 8 bit ÷ 5 = 32 caratteri: è la lunghezza del segreto che vedrai nella pagina di registrazione.
=. Il risultato è
"il codice è sempre sbagliato" senza nessun messaggio d'errore utile, perché l'app decodifica
byte diversi da quelli del server.
04Passo 3 — Il tempo
Il secondo ingrediente non viaggia affatto: è l'orologio. RFC 6238 lo formalizza così:
T = floor((tempo_unix_corrente - T0) / X)
// T0 = 0 -> si parte dall'epoch Unix (1 gennaio 1970)
// X = 30 -> ogni finestra dura 30 secondi
const PERIOD = 30;
const T0 = 0;
function timeCounter(unixSeconds = Math.floor(Date.now() / 1000), period = PERIOD) {
return Math.floor((unixSeconds - T0) / period);
}
function secondsRemaining(unixSeconds = Math.floor(Date.now() / 1000), period = PERIOD) {
return period - ((unixSeconds - T0) % period);
}
Il valore mostrato in cima alla pagina è proprio questo T. Il tuo telefono, adesso,
sta calcolando lo stesso identico numero. È l'unica cosa che i due dispositivi "si dicono", e se
la dicono senza parlarsi.
05Passo 4 — Da segreto + tempo a sei cifre
Qui sta il cuore, definito da RFC 4226 §5.3. Quattro passaggi.
4.1 — Il contatore diventa 8 byte
Big-endian, sempre 8 byte. Con 4 byte "funziona" fino al giorno dell'overflow: è un bug che si presenta anni dopo il rilascio.
const counterBuf = Buffer.alloc(8);
counterBuf.writeBigUInt64BE(BigInt(counter));
4.2 — HMAC-SHA1
È qui che entra il segreto. HMAC è una funzione a senso unico con chiave: dallo stesso
(chiave, messaggio) esce sempre lo stesso hash, ma dall'hash non si risale alla chiave.
Chi non ha il segreto non può produrre l'output giusto.
const hs = crypto.createHmac('sha1', key).update(counterBuf).digest(); // 20 byte
algorithm: usare SHA256 romperebbe la compatibilità
con l'app più diffusa. Le RFC ammettono SHA256 e SHA512, e altre app li supportano.
4.3 — Dynamic truncation: da 20 byte a 31 bit
Servono 6 cifre, non 160 bit. Ma non si prendono semplicemente i primi byte: si usa l'hash stesso per decidere da dove leggere.
// i 4 bit bassi dell'ULTIMO byte dicono da dove iniziare a leggere (0..15)
const offset = hs[hs.length - 1] & 0x0f;
const binary =
((hs[offset] & 0x7f) << 24) | // 0x7f azzera il bit più alto
((hs[offset + 1] & 0xff) << 16) |
((hs[offset + 2] & 0xff) << 8) |
(hs[offset + 3] & 0xff);
Due dettagli che sembrano arbitrari e non lo sono:
- l'offset variabile evita di privilegiare sempre la stessa porzione dell'hash;
- la maschera
0x7fazzera il bit di segno. La RFC lo dice esplicitamente: serve "a evitare confusione fra modulo con segno e senza segno" — in linguaggi dove l'intero è signed, un bit alto acceso produrrebbe un numero negativo e un modulo diverso.
4.4 — Modulo e padding
const code = String(binary % 10 ** 6).padStart(6, '0');
Lo zero-padding non è cosmetico: senza, un risultato come 42 diventerebbe un codice
di due cifre e il confronto fallirebbe.
Mettendo insieme tutto — TOTP = HOTP(segreto, T):
function generateTOTP(secretBase32, { at = Math.floor(Date.now() / 1000) } = {}) {
return hotp(secretBase32, timeCounter(at));
}
Sono venti righe. Non c'è nient'altro.
06Passo 5 — Consegnare il segreto al telefono
Il QR code non è magia e non contiene l'algoritmo: contiene una stringa di testo, in un formato definito da Google e diventato standard di fatto — il Key URI Format.
otpauth://totp/Demo%20TOTP:giuseppe?secret=JBSWY3DPEHPK3PXP&issuer=Demo%20TOTP&algorithm=SHA1&digits=6&period=30
| Parametro | Obbligo | Valori | Default |
|---|---|---|---|
secret | richiesto | Base32 senza padding | — |
issuer | fortemente raccomandato | nome del servizio | — |
algorithm | opzionale | SHA1 · SHA256 · SHA512 | SHA1 |
digits | opzionale | 6 · 8 | 6 |
period | opzionale | secondi | 30 |
algorithm, digits e
period sono ignorati da Google Authenticator. Se generi un URI con
digits=8, funziona su Aegis e 1Password e si rompe su Google Authenticator senza
errori: mostra semplicemente codici che il server rifiuta. Per la compatibilità con GA:
SHA1, 6 cifre, 30 secondi. Sempre.
function buildOtpauthURI({ secret, account, issuer }) {
if (String(account).includes(':') || String(issuer).includes(':')) {
throw new Error('issuer e account non possono contenere il carattere ":"');
}
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
// NB: encodeURIComponent, NON URLSearchParams: quest'ultimo codifica lo spazio
// come "+" (form-encoding) mentre otpauth richiede "%20" (RFC 3986).
const params = [
['secret', secret], ['issuer', issuer],
['algorithm', 'SHA1'], ['digits', '6'], ['period', '30'],
].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
return `otpauth://totp/${label}?${params}`;
}
E il disegno del QR è una riga, con la libreria qrcode:
const QRCode = require('qrcode');
const qrDataUrl = await QRCode.toDataURL(otpauthUri, { margin: 2, width: 260 });
// -> "data:image/png;base64,..." da mettere direttamente in <img src>
07Passo 6 — La registrazione
Due chiamate, e la seconda è quella che quasi tutti i tutorial saltano.
6.1 — Credenziali, segreto, QR
app.post('/api/register/start', async (req, res) => {
const passwordHash = pwd.hashPassword(password); // primo fattore, subito in hash
const secret = totp.generateSecret(); // secondo fattore, creato UNA volta
store.createPending(username, passwordHash, secret); // salvato come NON confermato
const otpauthUri = totp.buildOtpauthURI({ secret, account: username, issuer: ISSUER });
const qrDataUrl = await QRCode.toDataURL(otpauthUri, { margin: 2, width: 260 });
res.json({ secret, otpauthUri, qrDataUrl });
});
6.2 — Conferma prima di attivare
app.post('/api/register/confirm', (req, res) => {
const user = store.get(req.body.username);
const esito = totp.verifyTOTP(user.secret, req.body.token);
if (!esito.valid) return res.status(401).json({ error: esito.reason });
store.update(user.username, {
confirmed: true, // solo ora il 2FA è attivo
lastUsedCounter: esito.counter, // anti-replay, vedi sotto
});
res.json({ ok: true });
});
08Passo 7 — Il login a due fattori
Il login è in due chiamate separate, apposta: rende visibile che il secondo fattore si verifica dopo il primo, e che superare la password da sola non autentica nessuno.
7.1 — Primo fattore: la password
Se è corretta, il server non emette una sessione: emette un challenge, una sessione parziale a scadenza breve che dà solo il diritto di presentare il secondo fattore.
app.post('/api/login/password', (req, res) => {
const user = store.get(username);
const generico = { error: 'Username o password non validi.' };
if (!user || !user.confirmed) {
// hash fittizio: senza, il tempo di risposta rivelerebbe se l'utente esiste
pwd.verifyPassword(password, pwd.hashPassword('placeholder'));
return res.status(401).json(generico);
}
if (!pwd.verifyPassword(password, user.passwordHash)) {
registraFallimento(user, Date.now());
return res.status(401).json(generico);
}
const challenge = nuovoChallenge(user.username); // NON è una sessione autenticata
res.json({ ok: true, challenge });
});
7.2 — Secondo fattore: la finestra di tolleranza
Fra quando l'app mostra il codice e quando il server lo riceve passano secondi: l'utente legge, digita, la rete ritarda. Se il codice scade nel frattempo, il login fallisce pur essendo corretto. La soluzione è accettare anche le finestre adiacenti — ±1, non di più, come raccomanda RFC 6238 §5.2.
const current = timeCounter(at);
for (let drift = -window; drift <= window; drift++) { // window = 1
const counter = current + drift;
const { code } = hotp(secretBase32, counter);
if (timingSafeEqualStr(code, candidate)) { /* valido */ }
}
Allargare la finestra è una tentazione: con ±10 quasi nessun login fallisce mai. Ma significa accettare 21 codici validi in ogni istante invece di 3 — sette volte più facile da indovinare. La finestra è un compromesso di sicurezza, non una manopola del comfort.
7.3 — Anti-replay
Un codice resta valido fino a 90 secondi (finestra ±1). Chi lo intercetta — spalle, keylogger, sito di phishing — ha quella finestra per riusarlo. Si chiude memorizzando l'ultimo contatore consumato:
if (lastUsedCounter !== null && counter <= lastUsedCounter) {
return { valid: false, reason: 'Codice già utilizzato: attendi il prossimo.' };
}
È il motivo per cui, subito dopo la registrazione, il login con lo stesso codice viene rifiutato: quella finestra è già stata bruciata dalla conferma. Provalo, è istruttivo.
7.4 — Confronto a tempo costante
Un normale === fra stringhe esce al primo carattere diverso. Misurando i tempi di
risposta si scopre quante cifre iniziali erano giuste, e si ricostruisce il codice cifra
per cifra: 60 tentativi invece di un milione.
function timingSafeEqualStr(a, b) {
const bufA = Buffer.from(a, 'utf8'), bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB); // tempo indipendente dal contenuto
}
7.5 — Throttling
Sei cifre sono un milione di possibilità: senza limiti, uno script le prova tutte. RFC 4226 §7.3 chiede un limite ai tentativi falliti.
const MAX_TENTATIVI = 5, BLOCCO_SECONDI = 60;
if (user.lockedUntil && Date.now() < user.lockedUntil) {
return res.status(429).json({ error: 'Troppi tentativi falliti.' });
}
// a ogni fallimento incrementa failedAttempts; al quinto imposta lockedUntil
09Consegnare il segreto in sicurezza
Tutto quello che hai letto finora descrive l'algoritmo. Ma il momento in cui il meccanismo e' davvero fragile e' un altro: l'enrollment, cioe' il passaggio del segreto dal server al telefono. E' l'unico istante in cui quel valore esiste in chiaro fuori dal database.
9.1 — Richiedi di nuovo la password, anche a sessione aperta
E' la regola piu' importante di tutte, ed e' quella che viene saltata piu' spesso. Chi trova il tuo computer sbloccato e collega il proprio telefono diventa lui il secondo fattore: da quel momento ha una credenziale legittima e permanente. Nessuna crittografia e' stata toccata.
app.post('/api/2fa/enroll', requireAuth, requireRecentPassword, async (req, res) => {
// requireRecentPassword: la password e' stata reinserita negli ultimi N minuti.
// Vale anche per DISATTIVARE il 2FA: senza, si aggira tutto dal pannello impostazioni.
});
9.2 — Il segreto vive in uno stato provvisorio
Non entra nel record utente attivo finche' l'utente non ha dimostrato di averlo ricevuto. Sta in uno stato pending legato alla sessione, con scadenza breve. Le specifiche non fissano un TTL: 10–15 minuti e' pratica di settore.
9.3 — Mostrato una volta, poi distrutto
“Mostrato una volta” senza distruzione e' solo una promessa dell'interfaccia. Dopo la conferma il segreto va rimosso davvero, e non deve restare in nessuna cache.
Qui c'e' pero' un'asimmetria da capire bene: il backend non puo' distruggere il segreto. Gli serve per sempre, per ricalcolare il codice a ogni login. E' l'opposto della password, che dopo l'hash e' irrecuperabile per costruzione. La regola precisa e' quindi:
Nel frontend
- Mai
localStorage,sessionStorage, IndexedDB o cookie. Nemmeno temporaneamente. - Rimuovere dal DOM dopo la conferma, QR compreso.
- Il segreto non deve essere un
<input>: autofill e password manager catturano i campi form. - Session replay e analytics (Sentry Replay, Hotjar, FullStory) registrano il DOM: il QR finisce sui loro server. E' il canale di fuga piu' sottovalutato — vanno mascherati esplicitamente o disattivati su quella pagina.
function distruggiSegreto() {
const qr = document.getElementById('qr');
qr.src = ''; // svuota prima di rimuovere
qr.remove();
document.getElementById('secret').remove();
document.getElementById('uri').remove();
segretoInMemoria = null; // vedi il limite qui sotto
}
variabile = null rimuove il riferimento, non il contenuto. Quello che ottieni e'
ridurre la finestra e impedire la persistenza, non l'azzeramento. Per azzerare sul serio servono
Uint8Array/Buffer riempiti di zeri (buf.fill(0)), e vale
sia nel browser sia in Node.
Nel backend
res.set('Cache-Control', 'no-store'); // NON 'private': quello consente comunque
res.set('Pragma', 'no-cache'); // la cache del browser, e la bfcache
- Mai nei log. Il colpevole tipico non e' un
console.logesplicito: sono i logger strutturati che serializzano automaticamente body di richieste e risposte, e gli stack trace che includono il contesto. - Il PNG del QR generato in memoria: mai su disco, mai a un URL come
/qr/utente.png, che sarebbe una risorsa recuperabile e cacheabile. - Il segreto mai in un URL (
/enroll?secret=...): finisce in cronologia, nei log del proxy e nell'headerReferer. - Nessun proxy o CDN che cachi quella risposta.
- Record pending eliminato alla conferma o alla scadenza.
9.4 — Subito dopo l'attivazione
- Codici di recupero, generati in quel momento, mostrati una volta, salvati come hash. Senza, il 2FA e' una serratura senza chiave di scorta: telefono perso, account perso.
- Notifica fuori banda (“il 2FA e' stato attivato”): se non e' stato l'utente, deve poterlo scoprire. Non e' un requisito normativo esplicito, ma e' pratica consolidata.
- Invalidazione delle altre sessioni, cosi' un eventuale intruso viene espulso.
9.5 — Cifratura a riposo
Il segreto TOTP e' l'unico dei due fattori che il server deve conservare in forma utilizzabile. Chi legge quel campo genera codici validi a tempo indeterminato. Va cifrato con AES-GCM e chiave in un KMS o in variabile d'ambiente — mai nel codice, mai nello stesso posto del dato cifrato.
Dove sta questa demo
| Pratica | Qui |
|---|---|
| Generazione server-side, 160 bit | si' |
| Consegna su HTTPS | si' |
| Conferma prima dell'attivazione | si' |
| Stato pending separato dall'attivo | si' |
| Throttling dei tentativi | si' (5, contro il tetto NIST di 100) |
no-store sulla risposta di enrollment | si' |
| Distruzione del segreto nel DOM dopo la conferma | si' |
| Re-autenticazione prima dell'enrollment | no — la registrazione e' aperta a chiunque |
| Visualizzazione una sola volta | no — il pannello didattico lo ri-mostra di proposito |
| Cifratura a riposo | no — in chiaro, per poterlo mostrare |
| Codici di recupero | no |
Le prime tre assenze non sono dimenticanze: sono il prezzo consapevole di una demo che deve far vedere il meccanismo. In un'applicazione vera sono obbligatorie.
10Le librerie usate
| Pacchetto | A cosa serve | Sostituibile con |
|---|---|---|
express | server HTTP e routing | il modulo http di Node, Fastify, Koa |
qrcode | disegna l'URI otpauth come PNG | qrcode-generator, o generazione lato browser |
node:crypto | HMAC-SHA1, byte casuali, scrypt, confronto a tempo costante | è già in Node, nessuna dipendenza |
Due dipendenze in tutto. L'algoritmo TOTP è scritto a mano, in
lib/totp.js: è il senso di questo corso. In un progetto vero useresti una libreria
collaudata:
// npm install otplib
const { authenticator } = require('otplib');
const secret = authenticator.generateSecret();
const token = authenticator.generate(secret);
const ok = authenticator.check(token, secret);
const uri = authenticator.keyuri('giuseppe', 'Demo TOTP', secret);
Alternative equivalenti: speakeasy (storica, meno mantenuta),
@noble/hashes più poche righe (per chi vuole dipendenze minime),
pyotp in Python, otp-java in Java.
11Verificare che l'implementazione sia giusta
Un'implementazione TOTP può sembrare funzionante e non essere interoperabile: genera codici coerenti con sé stessa, ma diversi da quelli dell'app. Le RFC pubblicano vettori di test ufficiali proprio per questo — se passano, il codice è compatibile con qualunque autenticatore.
// RFC 4226 Appendice D — segreto ASCII "12345678901234567890"
const HOTP_VECTORS = ['755224','287082','359152','969429','338314',
'254676','287922','162583','399871','520489'];
HOTP_VECTORS.forEach((atteso, counter) => {
assert.strictEqual(totp.hotp(SECRET_B32, counter).code, atteso);
});
// RFC 6238 Appendice B — TOTP, SHA1, 8 cifre
assert.strictEqual(totp.generateTOTP(SECRET_B32, { at: 59, digits: 8 }).code, '94287082');
assert.strictEqual(totp.generateTOTP(SECRET_B32, { at: 1111111109, digits: 8 }).code, '07081804');
$ npm test
HOTP — RFC 4226, Appendice D (contatori 0..9)
ok counter=0 -> 755224
...
29 test superati, 0 falliti
lib/totp.js produce esattamente i valori pubblicati nelle specifiche.
Non "sembra funzionare": è dimostrabilmente conforme.
12Gli errori che si fanno davvero
| Sintomo | Causa |
|---|---|
| Il codice è sempre sbagliato, da subito | Segreto in Base64 invece che Base32, o padding = non rimosso |
| Funzionava, poi ha smesso | Orologio del server o del telefono fuori sincrono (> 30 s). Serve NTP |
| Funziona su Aegis, non su Google Authenticator | digits/period/algorithm non standard: GA li ignora |
| L'utente resta chiuso fuori dopo la registrazione | Segreto attivato senza il passo di conferma |
| Il codice smette di valere dopo anni | Contatore serializzato su 4 byte invece di 8 |
L'issuer appare come Demo+TOTP | URLSearchParams al posto di encodeURIComponent |
| Login forzabile a tentativi | Finestra di validazione troppo ampia, o throttling assente |
| Si scopre quali username esistono | Messaggi d'errore diversi, o tempi di risposta diversi |
13Cosa manca a questa demo
Quello che hai in mano è completo dal punto di vista dell'algoritmo e incompleto come applicazione. Per un uso reale servirebbe:
- Il segreto TOTP cifrato a riposo. Chi legge il database genera codici validi per sempre. In produzione: AES-GCM con chiave in un KMS o in variabile d'ambiente, mai nel codice. Qui è in chiaro apposta, per poterlo mostrare nel pannello didattico.
- Codici di recupero monouso. I telefoni si perdono e si rompono. Senza una via di recupero, il 2FA diventa una serratura senza chiave di scorta.
- Sessioni vere — cookie firmati,
HttpOnly,Secure, protezione CSRF — e HTTPS: senza, password e codice viaggiano in chiaro. - Throttling per IP oltre che per utente, e log dei tentativi.
- Un database vero al posto del file JSON, con scritture atomiche.
- Il pannello didattico rimosso. Si spegne già con
DIDATTICA=off, ma in produzione quel codice non dovrebbe proprio esistere.
Vale anche la pena sapere che TOTP non protegge dal phishing in tempo reale: un sito falso può farsi dare password e codice e usarli entro 30 secondi. Le passkey / WebAuthn risolvono anche quello, perché legano la credenziale al dominio. TOTP resta però il compromesso migliore fra sicurezza, costo e compatibilità universale.
14Ora provalo
Le due pagine mostrano dal vivo tutto ciò che hai letto. Nella registrazione vedrai il segreto e l'URI in chiaro; nel login un pannello ricalcola il codice atteso passo per passo — HMAC compreso — così puoi confrontarlo con quello che mostra il telefono.
Fonti: RFC 6238 (TOTP) ·
RFC 4226 (HOTP) ·
Google Authenticator Key Uri Format.
Sintesi verificata in docs/research/totp-google-authenticator.md.