Quando vi registrate su un sito fatto bene accade una cosa che vale la pena rendere esplicita: il sito non conserva la vostra password. Non la conosce, non può leggerla, non potrebbe rispedirvela nemmeno volendo — e non potrebbe consegnarla a nessuno, nemmeno se glielo ordinassero.
Non è una promessa commerciale: è una proprietà matematica di come sono memorizzati i dati. Vale la pena capire esattamente da dove viene, perché è la stessa proprietà che distingue un sistema di autenticazione serio da uno che sembra serio.
Hash non è cifratura#
La confusione fra i due termini è all'origine di quasi tutti gli errori in questa area. Cifrare significa trasformare un dato in modo reversibile: chi possiede la chiave rilegge l'originale — è il punto della cifratura, è ciò per cui esiste. Applicare un hash significa trasformarlo in modo irreversibile.
// Cifratura: reversibile per progetto. Chi ha la chiave rilegge il testo.const cifrato = cifra(chiave, 'passw0rd!');decifra(chiave, cifrato); // → 'passw0rd!'// Hash: irreversibile per costruzione. Non esiste una funzione inversa,// non perché non l'abbiamo scritta, ma perché l'informazione è andata persa.const digest = sha256('passw0rd!');// → 'd4e5f6...' e da qui non si torna indietroL'irreversibilità non è una questione di forza bruta: non è che invertire un hash sia «difficile». È che l'informazione necessaria a farlo non c'è più. Una funzione di hash comprime un input di lunghezza arbitraria in un output di lunghezza fissa: infiniti input diversi producono lo stesso digest, quindi non esiste alcuna funzione che, dato il digest, sappia dire da quale input veniva.
Il sale, e cosa non è#
Il sale è la parte più fraintesa del meccanismo, di solito perché viene descritta come «un dato che rende l'hash più difficile da indovinare». Non è quello che fa.
// Senza sale: password uguali → hash uguali. Il database diventa un indice// per frequenza: l'hash più ripetuto è quasi certamente "123456".sha256('123456') // → '8d969eef6ecad3c29a3a629280e686cf...'sha256('123456') // → '8d969eef6ecad3c29a3a629280e686cf...' identico// Con il sale: 16 byte casuali per utente, memorizzati accanto all'hash.// Il sale NON è un segreto — è un disambiguatore. Il suo scopo è rendere// inutile ogni tabella precalcolata e impedire il confronto fra utenti.bcrypt.hashSync('123456', bcrypt.genSaltSync(12));// → '$2b$12$Xy3kQ.../ZH8mP2...'bcrypt.hashSync('123456', bcrypt.genSaltSync(12));// → '$2b$12$Ab9wR.../Kf1nT7...' stessa password, hash diversoIl sale non è un segreto: viaggia in chiaro, memorizzato accanto all'hash — anzi, dentro l'hash. Il suo scopo è togliere all'attaccante due economie di scala:
- Le tabelle precalcolate. Senza sale, un attaccante può calcolare una volta sola gli hash delle dieci milioni di password più comuni e riusare quella tabella contro ogni database che ruberà, per sempre. Con un sale per utente, ogni tabella andrebbe ricalcolata per ogni singolo utente: il precalcolo perde senso.
- Il confronto fra righe. Senza sale, due utenti con lo stesso hash hanno la stessa password. Il database diventa un istogramma: l'hash che compare quattromila volte è «123456», e lo si scopre senza rompere niente.
Cosa c'è dentro una stringa bcrypt#
L'hash bcrypt non è una stringa opaca: è un formato con una struttura precisa, e leggerla spiega da sola metà del funzionamento.
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj/nR6.iRHy2│ │ │└──────── 22 caratteri ────────┘└───── 31 caratteri ─────┘│ │ │ sale (128 bit) hash (184 bit)│ │ └── cost factor: 12 → 2^12 = 4096 iterazioni del key schedule│ └───── cost su due cifre, sempre└──────── identificativo di versione ($2a$, $2b$, $2y$)60 caratteri in tutto: sale e parametri viaggiano DENTRO l'hash.Per questo la colonna del database non ha bisogno di altri campi, e perquesto si può alzare il cost senza migrare nulla: ogni riga porta il proprio.Il dettaglio che conta è che i parametri viaggiano dentro il dato. Ogni riga del database porta con sé il cost con cui è stata calcolata, quindi righe con cost diversi convivono senza problemi e alzare il parametro non richiede alcuna migrazione: i vecchi hash restano verificabili, i nuovi nascono più forti.
Sull'identificativo di versione: $2b$ è quello da usare. $2a$ è la versione storica, in alcune implementazioni affetta da un difetto di gestione della lunghezza per password molto lunghe; $2y$ è la correzione introdotta da PHP per lo stesso problema. Verificarli è utile quando si eredita un database altrui: la versione dice in che stato di manutenzione era il sistema.
La verifica, che non decifra niente#
// La verifica non "decifra" niente: rilegge il cost e il sale dalla stringa// memorizzata, rifà lo stesso calcolo sulla password appena digitata e// confronta i risultati in tempo costante.const ok = await bcrypt.compare(passwordInserita, utente.passwordHash);// Il server, in nessun momento di questa operazione, ha saputo qual era// la password memorizzata. Non può dirvela, non può rimandarvela,// non può cederla a nessuno — nemmeno sotto ordine di un tribunale.Questa è la proprietà interessante, e vale la pena dirla con parole non tecniche, perché è ciò che va spiegato a un cliente: il gestore del sito non è in grado di conoscere le password dei propri utenti. Non è una regola interna che qualcuno potrebbe violare — è una cosa che non può fare.
Perché una funzione di hash normale non basta#
SHA-256 è un ottimo hash. È anche velocissimo, ed è esattamente per questo che è la scelta sbagliata per le password: la velocità che serve a firmare un file è la stessa che serve all'attaccante a provare miliardi di candidati.
L'ordine di grandezza si legge dai benchmark pubblici di hashcat, che chiunque può rieseguire sul proprio hardware:
hashcat -b -m 1400 # SHA-256 grezzoSpeed.#1.........: 21847.2 MH/shashcat -b -m 3200 # bcrypt, cost 5Speed.#1.........: 184.1 kH/s# cost 5 e' il default del benchmark: ogni +1 dimezza.# A cost 12 (2^7 = 128 volte piu' lavoro) si scende a ~1.4 kH/s.bcrypt è lento per progetto. Il parametro di costo controlla quante volte viene ripetuto il key schedule di EksBlowfish, un'operazione scelta perché richiede accessi frequenti a una tabella in memoria — cioè perché si parallelizza male su hardware dedicato.
Tarare il cost factor#
Questo è il punto in cui la maggior parte delle implementazioni si limita a copiare un numero. Il cost giusto dipende dalla CPU su cui gira il vostro server, e va misurato lì:
// Il cost factor NON si copia da un articolo: si misura sull'hardware su cui// girerà davvero. Un valore preso da un blog del 2015 su un server del 2026// è al tempo stesso troppo lento per l'utente e troppo veloce per l'attaccante.const bcrypt = require('bcrypt');for (const cost of [10, 11, 12, 13, 14]) { const t0 = process.hrtime.bigint(); await bcrypt.hash('password-di-prova-lunga-abbastanza', cost); const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log(`cost ${cost}: ${ms.toFixed(0)} ms`);}// Si sceglie il cost più alto che resta sotto i ~250 ms sul server di// produzione. Sopra, il login "pesa"; sotto, si regala lavoro all'attaccante.node taratura.jscost 10: 62 mscost 11: 124 mscost 12: 247 ms ← sceltocost 13: 493 mscost 14: 986 ms# ogni +1 raddoppia il lavoro: e' una scala logaritmica, non lineareIl criterio: il valore più alto che tiene la verifica sotto i 250 millisecondi sull'hardware di produzione. Sotto quella soglia l'utente non percepisce nulla; sopra, il login comincia a sembrare lento. E poiché ogni incremento raddoppia il lavoro, il parametro va rivisto ogni paio d'anni: l'hardware degli attaccanti migliora, il vostro server no.
Il rovescio della medaglia: l'hash costoso è un vettore di DoS#
Questa parte manca in quasi tutti gli articoli sull'argomento, ed è quella che si paga in produzione. Un hash che costa 250 ms di CPU costa 250 ms anche a voi. Significa che circa quattro tentativi di login al secondo saturano un core.
// Il rovescio della medaglia che quasi nessuno cita: un hash costoso è// costoso anche PER VOI. 250 ms di CPU per tentativo significa che ~4// richieste di login al secondo saturano un core.//// Senza rate limiting, l'endpoint di login diventa un amplificatore di DoS:// l'attaccante spende una richiesta HTTP, voi spendete un quarto di secondo// di CPU. Il rapporto di costo è a suo favore.app.post('/login', rateLimit({ finestra: 15 * 60_000, max: 10, chiave: req => req.ip }), rateLimit({ finestra: 15 * 60_000, max: 5, chiave: req => req.body.email }), gestisciLogin);// Due limiti, non uno: per IP ferma il rumore di fondo, per account ferma// il credential stuffing distribuito su migliaia di IP diversi.Il limite dei 72 byte#
bcrypt considera solo i primi 72 byte dell'input e ignora silenziosamente il resto. Non 72 caratteri: byte — con accenti, ideogrammi o emoji il limite arriva molto prima di quanto suggerisca la lunghezza visibile.
// bcrypt considera solo i primi 72 BYTE. Non caratteri: byte.// Con caratteri accentati o emoji il limite arriva prima del previsto.const a = 'A'.repeat(72) + 'password-completamente-diversa';const b = 'A'.repeat(72) + 'qualunque-altra-cosa';await bcrypt.compare(b, await bcrypt.hash(a, 12)); // → true (!)// ✗ La toppa sbagliata: pre-hash esadecimale.// sha256 in hex produce 64 caratteri ASCII: rientra nei 72 byte, ma// si buttano via 256 bit di entropia comprimendoli in 64 byte di alfabeto// ristretto — e soprattutto si introduce il rischio di null byte se// qualche implementazione tronca su \0.const debole = await bcrypt.hash(sha256hex(password), 12);// ✓ La forma corretta, se dovete restare su bcrypt: pre-hash in base64.// 256 bit → 44 caratteri, nessun byte nullo, entropia intatta.const digest = crypto.createHash('sha256').update(password, 'utf8').digest('base64');const hash = await bcrypt.hash(digest, 12);In pratica il caso è raro: pochissimi utenti scelgono password oltre i settanta caratteri. Ma chi lo fa è tipicamente chi usa un gestore di password con passphrase lunghe generate a macchina — cioè esattamente gli utenti che state penalizzando senza dirglielo.
Argon2id, e perché oggi si parte da lì#
// Per un progetto nuovo, oggi, la scelta di partenza è Argon2id.// La differenza non è "più moderno": è che bcrypt è costoso in TEMPO,// Argon2id è costoso in MEMORIA. Una GPU ha migliaia di core ma poca RAM// per core, e un ASIC la memoria deve comprarla: il vantaggio dell'hardware// dedicato si riduce di ordini di grandezza.const argon2 = require('argon2');const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB — il minimo raccomandato da OWASP timeCost: 2, // passaggi sulla memoria parallelism: 1});// La verifica rilegge i parametri dalla stringa, esattamente come bcrypt:// $argon2id$v=19$m=19456,t=2,p=1$<sale>$<hash>await argon2.verify(hash, passwordInserita);La differenza rispetto a bcrypt non è la modernità: è quale risorsa viene resa costosa. bcrypt costa tempo di CPU, e una GPU di fascia alta ha migliaia di unità di calcolo. Argon2id costa memoria: con 19 MiB per tentativo, una scheda con 24 GB di VRAM ne esegue al massimo un migliaio in parallelo, contro le decine di migliaia che riuscirebbe a fare su un hash che la memoria non la usa. Il vantaggio dell'hardware dedicato collassa.
| Algoritmo | Risorsa resa costosa | Parametri di partenza | Quando |
|---|---|---|---|
| Argon2id | memoria + tempo | m=19 MiB, t=2, p=1 | progetti nuovi: è la scelta predefinita |
| scrypt | memoria + tempo | N=2^17, r=8, p=1 | valida alternativa, disponibile in Node senza dipendenze |
| bcrypt | tempo | cost ≥ 12, misurato | sistemi esistenti, ecosistemi dove è lo standard |
| PBKDF2-HMAC-SHA256 | tempo | 600 000 iterazioni | solo se imposto da un vincolo di conformità (FIPS) |
| SHA-256, MD5, SHA-1 | niente | — | mai, con o senza sale |
Il pepper: l'unico segreto vero#
Il sale non è segreto, e questo lascia scoperto un caso specifico: il furto del solo database. SQL injection, un backup finito su uno storage pubblico, una replica di lettura dimenticata senza autenticazione. In quello scenario l'attaccante ha gli hash e i sali, e può cominciare a provare password deboli con tutta calma.
// Il pepper è un segreto applicativo unico, che NON sta nel database.// Serve a un caso preciso: il dump del solo database (SQL injection, backup// esposto, replica dimenticata). Senza il pepper, gli hash rubati sono// inattaccabili anche con password deboli.const pepe = process.env.PASSWORD_PEPPER; // 32 byte, in un secret managerconst hash = await argon2.hash(password, { ...PARAMETRI, secret: Buffer.from(pepe, 'base64') // Argon2 lo prevede nativamente});// Con bcrypt, che non ha il parametro, si applica prima un HMAC:const pepato = crypto.createHmac('sha256', pepe).update(password).digest('base64');await bcrypt.hash(pepato, COST);Il pepper è un segreto applicativo che non sta nel database: sta in un secret manager, in una variabile d'ambiente, idealmente in un HSM. Senza di esso, gli hash rubati sono inservibili anche per la password «123456». Il prezzo è che ruotarlo richiede di ricalcolare tutto al login successivo, come per il cost factor.
Migrare senza conoscere le password#
Domanda ricorrente: se non conosco le password degli utenti, come passo da MD5 ad Argon2id? La risposta è che c'è esattamente un istante in cui la password in chiaro esiste — il login riuscito — e si sfrutta quello.
// Alzare il cost, o passare da bcrypt ad Argon2id, non richiede di conoscere// le password: si aggiorna l'hash al primo login riuscito, quando la password// in chiaro è transitoriamente disponibile in memoria.async function login(email, password) { const u = await utenti.perEmail(email); if (!u) { await bcrypt.hash(password, COST); return null; } // vedi nota if (!await verifica(u.passwordHash, password)) return null; // Da qui in poi l'utente è autenticato: se il suo hash è vecchio, si rifà. if (daAggiornare(u.passwordHash)) { u.passwordHash = await argon2.hash(password, PARAMETRI); await utenti.salva(u); } return u;}function daAggiornare(hash) { if (hash.startsWith('$argon2id$')) return false; // già a norma if (hash.startsWith('$2b$')) return Number(hash.slice(4, 6)) < COST; return true; // md5, sha1, ...}Per gli account che non fanno login da anni resta un residuo di hash vecchi. Dopo un periodo ragionevole si forza un reset: sono comunque gli account su cui vale la pena passare dalla verifica dell'indirizzo email.
Errori che vale la pena non fare#
| Pratica | Perché sembra sensata | Perché non lo è |
|---|---|---|
| Hash lato client | «la password non attraversa la rete» | l'hash diventa la password: chi lo intercetta entra. Serve TLS, non questo. |
Doppio hash sha256(md5(p)) | «due sono meglio di uno» | resta veloce quanto il più veloce dei due: nessun costo aggiunto per l'attaccante |
| Sale unico per tutto il sito | «tanto è un sale» | non impedisce il confronto fra utenti né una tabella precalcolata mirata a voi |
| Cifrare invece di hashare | «così possiamo recuperarla» | la chiave è sullo stesso server del database: chi prende l'uno prende l'altra |
| Limitare la password a 16 caratteri | «sta nel campo del database» | la colonna contiene un hash a lunghezza fissa: il limite non ha nessuna causa tecnica |
| Vietare i caratteri speciali | «evita problemi di escaping» | il problema di escaping esiste altrove nel codice, e va risolto lì |
Un'ultima nota, che riguarda meno la crittografia e più il rispetto per l'utente: le regole di composizione obbligatoria — una maiuscola, un numero, un simbolo — e la scadenza periodica sono sconsigliate dal NIST da anni. Producono password prevedibili (Password1!, poi Password2!) e spingono le persone a scriversele. Quello che funziona è imporre una lunghezza minima generosa e confrontare la password scelta con gli elenchi delle credenziali già compromesse.