Il 7 maggio 2026 il Register ha pubblicato un dato che dovrebbe far riflettere chiunque gestisca un sistema di login: il 60% degli hash MD5 rubati in un data breach può essere craccato in meno di un’ora con hardware da poche centinaia di euro. La notizia, firmata da Brandon Vigliarolo, arrivava non a caso in occasione del World Password Day. Eppure nel 2026 troviamo ancora applicazioni Node.js in produzione che salvano le password con SHA-256 senza salt, o peggio con MD5 puro. Questo tutorial mostra come evitare quell’errore, passo dopo passo, usando Argon2id e bcrypt secondo i parametri raccomandati da OWASP.
Non è una guida teorica. Alla fine avrai un server Express funzionante con registrazione, login, migrazione automatica degli hash legacy, pepper lato server, rate limiting sui tentativi falliti e una suite di test. Il codice è pensato per essere copiato e adattato, non solo letto.
Perché l’hashing delle password conta ancora nel 2026
Molti sviluppatori pensano che il problema dell’hashing delle password sia “risolto” da anni. In parte è vero: le librerie esistono, sono mature, e implementarle richiede poche righe di codice. Il problema è che la teoria non basta se i parametri sono sbagliati o se manca un piano di migrazione per gli utenti esistenti.
Il Password Storage Cheat Sheet di OWASP è chiaro sul punto: le password vanno sempre sottoposte a hashing con un algoritmo lento e memory-hard come Argon2id, mai cifrate e mai passate attraverso funzioni veloci come SHA-256, che permettono a un attaccante di testare miliardi di combinazioni al secondo su una singola GPU. Argon2id vince il Password Hashing Competition del 2015 proprio perché è progettato per resistere sia agli attacchi con GPU sia a quelli con hardware dedicato (ASIC), grazie al suo consumo di memoria configurabile.
Anche il NIST, nella revisione 4 dello standard SP 800-63B, ha aggiornato le linee guida: niente più regole di complessità forzata (maiuscole, simboli, cifre obbligatorie), niente cambio password periodico senza un motivo concreto, ma password lunghe almeno 64 caratteri e controllo contro liste di password compromesse. Questo cambia anche il modo in cui progettiamo il layer di hashing: se accettiamo password fino a 64 o più caratteri, bcrypt (che tronca silenziosamente a 72 byte) diventa un problema da gestire esplicitamente, non un dettaglio da ignorare.
Se hai già letto il nostro confronto su costi di cracking tra bcrypt, Argon2 e scrypt, sai già perché la scelta dell’algoritmo cambia di ordini di grandezza il costo per un attaccante. Questa guida si concentra sulla parte che quell’articolo non copre: come implementarlo davvero, in un progetto Node.js reale, senza lasciare buchi.
Vale la pena chiarire subito una confusione comune tra principianti: hashing e cifratura non sono la stessa cosa, e non sono intercambiabili. La cifratura è reversibile, con la chiave giusta puoi tornare al testo originale, ed è pensata per dati che devi poter recuperare, come un indirizzo salvato nel profilo utente. L’hashing è a senso unico: non esiste una chiave che riporti l’hash alla password originale. Per questo motivo le password vanno sempre sottoposte a hashing, mai cifrate. Se un’applicazione cifra le password invece di sottoporle a hashing, un attaccante che compromette il server e recupera la chiave di cifratura ottiene tutte le password in chiaro in un colpo solo, uno scenario molto peggiore di dover craccare hash uno per uno.
Per i team che operano in Italia e in Europa c’è anche una dimensione normativa da considerare. L’articolo 32 del GDPR impone di adottare misure tecniche e organizzative adeguate al rischio, e la pseudonimizzazione e la cifratura dei dati personali sono citate esplicitamente come esempio di tali misure. Un hashing debole delle password, in caso di violazione dei dati, può diventare parte della valutazione che un’autorità di controllo come il Garante Privacy fa sull’adeguatezza delle misure adottate dal titolare del trattamento. Non è un dettaglio da soli sviluppatori: è un elemento che entra nella valutazione di conformità dell’intera organizzazione.
Prerequisiti: cosa ti serve prima di iniziare
Prima di scrivere codice, verifica di avere l’ambiente giusto. Le versioni contano: Argon2id e bcrypt si affidano a moduli nativi compilati, e versioni troppo vecchie di Node.js causano errori di build silenziosi durante l’installazione.
| Componente | Versione consigliata | Note |
|---|---|---|
| Node.js | 24.21.0 LTS (nome in codice Krypton) | Serve una toolchain di build funzionante per i moduli nativi |
| npm | 10.x o superiore | Incluso con Node.js 24 |
| argon2 (pacchetto npm) | 0.45.1 | Binding nativo di libargon2, richiede compilazione |
| bcrypt (pacchetto npm) | 6.0.0 | Alternativa a bcryptjs, più veloce ma nativa |
| express | 5.2.1 | Framework HTTP usato negli esempi |
| jest | ultima versione stabile | Per i test automatici del Passo 10 |
Ti serve anche una base minima di JavaScript asincrono (async/await), un editor di codice e, per i passi sul rate limiting, un’istanza Redis locale (bastano pochi minuti con Docker). Se il tuo progetto usa già Passport.js o un altro sistema di autenticazione, puoi comunque seguire questa guida e integrare solo la parte di hashing.
Passo 1 e 2: crea il progetto e installa le dipendenze
Parti da una cartella vuota e inizializza un progetto Node.js standard. Installiamo subito sia argon2 sia bcrypt: userai il primo come algoritmo principale e il secondo per gestire gli utenti storici che hanno ancora un hash bcrypt, oltre che come riferimento comparativo nei test.
mkdir password-hashing-demo && cd password-hashing-demo
npm init -y
npm install [email protected] [email protected] [email protected] dotenv express-rate-limit
npm install --save-dev jest supertest
Crea un file .env per i segreti che non devono mai finire nel repository. In questo file vivrà anche il pepper che useremo al Passo 6.
PORT=3000
PASSWORD_PEPPER=cambia-questo-valore-con-32-byte-casuali-in-produzione
NODE_ENV=development
Genera un pepper vero con node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" e non riutilizzarlo mai tra ambienti diversi (sviluppo, staging, produzione).
Passo 3: configura Argon2id secondo i parametri OWASP
Qui si gioca la partita più importante del tutorial. Il Password Storage Cheat Sheet di OWASP fissa una soglia minima precisa per Argon2id: memoria a 19 MiB, due iterazioni, un grado di parallelismo pari a 1. Sono valori minimi, non ottimali: se il tuo server ha risorse disponibili, alzali. La regola pratica è calibrare i parametri in modo che l’hashing di una singola password richieda tra 250 e 500 millisecondi sul tuo hardware di produzione, un compromesso ragionevole tra sicurezza e user experience.
// config/argon2.js
const argon2 = require('argon2');
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB, sopra il minimo OWASP di 19 MiB
timeCost: 3, // iterazioni, minimo OWASP e' 2
parallelism: 1, // minimo OWASP raccomandato
};
module.exports = { argon2, ARGON2_OPTIONS };
Nota che memoryCost è espresso in KiB nella libreria, quindi 65536 corrisponde a 64 MiB. Con questi parametri, su un server con CPU moderna, l’hashing richiede circa 150-300 millisecondi. Misuralo sul tuo hardware reale prima di andare in produzione: un valore troppo alto può saturare la CPU sotto carico, uno troppo basso vanifica il vantaggio di Argon2id rispetto ad algoritmi veloci.
Passo 4: endpoint di registrazione con Argon2id
Con la configurazione pronta, scriviamo l’endpoint che riceve email e password e genera l’hash. Argon2id include già il salt all’interno della stringa restituita, quindi non serve gestirlo manualmente come si farebbe con PBKDF2.
// routes/register.js
const express = require('express');
const crypto = require('crypto');
const { argon2, ARGON2_OPTIONS } = require('../config/argon2');
const db = require('../db');
const router = express.Router();
function applyPepper(password) {
return crypto
.createHmac('sha256', process.env.PASSWORD_PEPPER)
.update(password)
.digest('hex');
}
router.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 12) {
return res.status(400).json({ error: 'Email o password non validi' });
}
const existing = await db.findUserByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Utente gia registrato' });
}
const pepperedPassword = applyPepper(password);
const hash = await argon2.hash(pepperedPassword, ARGON2_OPTIONS);
await db.createUser({ email, passwordHash: hash, hashAlgo: 'argon2id' });
res.status(201).json({ message: 'Registrazione completata' });
});
module.exports = router;
Un dettaglio spesso trascurato: salviamo anche il campo hashAlgo. Ti servirà al Passo 7 per gestire la migrazione degli utenti che nel frattempo hanno ancora un hash generato con un algoritmo precedente.
Per completezza, ecco un'implementazione minima del modulo db.js usato negli esempi. In un progetto reale lo sostituirai con query verso Postgres, MySQL o MongoDB, ma la logica di business resta identica: cerca un utente per email, crea un nuovo record, aggiorna l'hash quando serve.
// db.js - versione dimostrativa in memoria, sostituiscila con un DB reale
const users = new Map();
let nextId = 1;
async function findUserByEmail(email) {
return [...users.values()].find((u) => u.email === email) || null;
}
async function createUser({ email, passwordHash, hashAlgo }) {
const user = { id: nextId++, email, passwordHash, hashAlgo };
users.set(user.id, user);
return user;
}
async function updateUserHash(id, { passwordHash, hashAlgo }) {
const user = users.get(id);
if (!user) return null;
user.passwordHash = passwordHash;
user.hashAlgo = hashAlgo;
return user;
}
module.exports = { findUserByEmail, createUser, updateUserHash };
Passo 5: login sicuro e verifica timing-safe
La funzione argon2.verify() è già progettata per essere resistente ai timing attack sul confronto dell'hash, quindi non serve wrapparla manualmente. Il punto delicato è un altro: se l'email non esiste nel database, il tempo di risposta non deve rivelare quell'informazione. Un attaccante che misura la latenza delle risposte può enumerare quali email sono registrate, anche senza mai indovinare una password.
// routes/login.js
const express = require('express');
const crypto = require('crypto');
const { argon2, ARGON2_OPTIONS } = require('../config/argon2');
const db = require('../db');
const router = express.Router();
const DUMMY_HASH =
'$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$ozOAvV3fN2Q5x1u8T7l9wQ';
function applyPepper(password) {
return crypto
.createHmac('sha256', process.env.PASSWORD_PEPPER)
.update(password)
.digest('hex');
}
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
const pepperedPassword = applyPepper(password || '');
// Se l'utente non esiste, verifichiamo comunque un hash fittizio
// per mantenere costante il tempo di risposta.
const hashToCheck = user ? user.passwordHash : DUMMY_HASH;
let valid = false;
try {
valid = await argon2.verify(hashToCheck, pepperedPassword);
} catch (err) {
valid = false;
}
if (!user || !valid) {
return res.status(401).json({ error: 'Credenziali non valide' });
}
res.json({ message: 'Login riuscito', userId: user.id });
});
module.exports = router;
Esempio di output con credenziali corrette:
$ curl -s -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"UnaPasswordLungaEComplessa123"}'
{"message":"Login riuscito","userId":42}
Con credenziali errate o email inesistente, la risposta è identica nel contenuto e vicina nel tempo di esecuzione:
$ curl -s -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"qualsiasi"}'
{"error":"Credenziali non valide"}
Se nel tuo sistema usi anche un secondo fattore, questo è il punto giusto per agganciarlo: dai un'occhiata alla nostra guida su TOTP e 2FA in Node.js per aggiungere un livello di verifica dopo il login con password.
Passo 6: aggiungi un pepper lato server con HMAC
Il salt protegge dagli attacchi con rainbow table precalcolate, ma vive nello stesso database dell'hash. Se un attaccante ruba l'intero database, ha anche tutti i salt. Il pepper aggiunge un secondo segreto, tenuto fuori dal database (in una variabile d'ambiente, in un secret manager, o in un HSM), che serve a rendere inutile un dump del solo database senza accesso anche al server applicativo.
OWASP raccomanda di applicare il pepper come costruzione HMAC prima dell'hashing vero e proprio, non semplicemente concatenandolo alla password come stringa. Il codice mostrato nei Passi 4 e 5 usa già questo approccio con crypto.createHmac('sha256', pepper). Un vantaggio pratico: se in futuro devi ruotare il pepper, puoi farlo mantenendo il vecchio valore per la verifica degli hash esistenti e usando il nuovo solo per le password create da quel momento in poi, con un campo pepperVersion accanto all'hash.
Passo 7: migra gli hash legacy MD5/SHA1 senza forzare il reset
Nella pratica, raramente parti da zero. Più spesso erediti un database con anni di hash generati con algoritmi ormai deboli, magari MD5 senza salt o SHA1 con un salt statico condiviso da tutti gli utenti, lo stesso schema che ha esposto FrontAccounting fino alla versione 2.4.20 secondo report di sicurezza del 2026. Forzare il reset immediato di tutte le password è invasivo e spesso porta a un tasso di abbandono elevato. La soluzione più diffusa è la migrazione lazy: converti l'hash al primo login riuscito dell'utente, quando hai ancora la password in chiaro a disposizione nella richiesta.
// dentro l'endpoint di login, dopo aver verificato la password
if (user.hashAlgo !== 'argon2id') {
const newHash = await argon2.hash(pepperedPassword, ARGON2_OPTIONS);
await db.updateUserHash(user.id, {
passwordHash: newHash,
hashAlgo: 'argon2id',
});
}
Per verificare un vecchio hash MD5 durante la transizione, scrivi una funzione dedicata che riconosce il formato legacy (lunghezza fissa di 32 caratteri esadecimali per MD5, ad esempio) e lo confronta con crypto.timingSafeEqual() invece di un semplice ===, che espone il confronto a timing attack su stringhe di lunghezza nota. La documentazione ufficiale di Node.js sul modulo crypto raccomanda esplicitamente questa funzione per confronti di valori segreti, avvertendo però che il codice circostante deve comunque essere scritto con attenzione per non reintrodurre una fuga di informazioni via timing.
const crypto = require('crypto');
function isLegacyMd5(hash) {
return typeof hash === 'string' && /^[a-f0-9]{32}$/i.test(hash);
}
function verifyLegacyMd5(password, storedHash) {
const computed = crypto.createHash('md5').update(password).digest('hex');
const a = Buffer.from(computed, 'hex');
const b = Buffer.from(storedHash, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Nota che questa funzione serve solo durante la finestra di transizione, per permettere agli utenti storici di autenticarsi un'ultima volta prima della conversione automatica ad Argon2id. Non deve mai essere il percorso di verifica principale per nuovi utenti, e vale la pena registrare un contatore di quante autenticazioni legacy avvengono ancora, per capire quando è ragionevole rimuovere questo codice del tutto.
Passo 8: bcrypt come alternativa e i suoi limiti
Se il tuo ambiente non può installare moduli nativi complessi, o se hai vincoli di compatibilità con sistemi legacy, bcrypt resta un'opzione valida secondo OWASP, a patto di usare un work factor pari o superiore a 10. Il pacchetto npm bcrypt in versione 6.0.0, il cui codice sorgente è mantenuto nel repository node.bcrypt.js su GitHub, è il binding nativo più diffuso, mentre bcryptjs (versione 3.0.3) è un'implementazione pura JavaScript, più lenta ma senza dipendenze da compilare, utile in ambienti serverless con limiti di build.
const bcrypt = require('bcrypt');
async function hashWithBcrypt(password) {
const saltRounds = 12; // sopra il minimo OWASP di 10
return bcrypt.hash(password, saltRounds);
}
async function verifyWithBcrypt(password, hash) {
return bcrypt.compare(password, hash);
}
Il limite più insidioso di bcrypt è il tetto di 72 byte: qualsiasi carattere oltre questa soglia viene silenziosamente ignorato, senza errore. Con l'indicazione NIST di accettare password fino a 64 o più caratteri, questo può creare una falsa sicurezza, con due password diverse dopo il 72esimo byte che risultano equivalenti per bcrypt. OWASP suggerisce di pre-hashare la password con SHA-256 prima di passarla a bcrypt proprio per aggirare questo limite, oppure di preferire Argon2id, che non ha questo vincolo.
scrypt è la terza opzione elencata da OWASP, con un profilo simile ad Argon2id: anche lui è memory-hard e resiste bene agli attacchi con hardware dedicato. In pratica lo trovi meno spesso in nuovi progetti Node.js perché l'ecosistema di librerie intorno ad Argon2id è oggi più maturo, ma resta la scelta corretta se per qualche motivo Argon2id non è disponibile nel tuo ambiente di esecuzione, ad esempio per restrizioni sulla compilazione di moduli nativi imposte da un provider cloud. Se lavori in un contesto regolamentato che richiede la conformità FIPS-140, nessuna delle tre opzioni precedenti è ammessa: dovrai usare PBKDF2 con HMAC-SHA-256 e almeno 600.000 iterazioni, il valore minimo indicato da OWASP per questa configurazione.
Passo 9: rate limiting e blocco account dopo tentativi falliti
Un algoritmo di hashing solido non serve a molto se un attaccante può provare migliaia di password al minuto contro il tuo endpoint di login. Aggiungi un limite sul numero di tentativi per indirizzo IP e, separatamente, per account, per bloccare sia il brute force distribuito sia il credential stuffing mirato su un singolo utente.
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minuti
max: 10, // 10 tentativi per IP nella finestra
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Troppi tentativi di login, riprova piu tardi' },
});
router.post('/login', loginLimiter, async (req, res) => {
// logica di login del Passo 5
});
Per un controllo più fine, abbina questo limite a un contatore per account salvato in Redis o nel database, che blocca temporaneamente l'account dopo un numero configurabile di fallimenti consecutivi (tipicamente 5), indipendentemente dall'IP di provenienza. Approfondisci l'argomento nella nostra guida dedicata al rate limiting in Node.js se devi proteggere anche altri endpoint sensibili oltre al login.
// blocco account dopo tentativi ripetuti, con Redis
const FAILED_ATTEMPTS_LIMIT = 5;
const LOCKOUT_SECONDS = 900; // 15 minuti
async function registerFailedAttempt(redisClient, email) {
const key = `login_fail:${email}`;
const attempts = await redisClient.incr(key);
if (attempts === 1) {
await redisClient.expire(key, LOCKOUT_SECONDS);
}
return attempts;
}
async function isAccountLocked(redisClient, email) {
const attempts = await redisClient.get(`login_fail:${email}`);
return Number(attempts || 0) >= FAILED_ATTEMPTS_LIMIT;
}
async function clearFailedAttempts(redisClient, email) {
await redisClient.del(`login_fail:${email}`);
}
Richiama isAccountLocked() subito dopo il rate limiter per IP e prima di eseguire argon2.verify(): se l'account è bloccato, restituisci lo stesso messaggio generico di credenziali non valide, senza rivelare che il blocco dipende dai tentativi falliti. Alla prima autenticazione riuscita, chiama clearFailedAttempts() per azzerare il contatore.
Passo 10, 11 e 12: test automatici, logging e checklist di produzione
Prima di distribuire il codice, copri almeno i casi critici con test automatici. Non testare i tempi esatti di hashing (dipendono dall'hardware), ma verifica che la logica di verifica accetti password corrette, rifiuti quelle errate e gestisca correttamente la migrazione degli hash legacy.
// __tests__/auth.test.js
const request = require('supertest');
const app = require('../app');
describe('Autenticazione', () => {
test('registra un nuovo utente e permette il login', async () => {
await request(app)
.post('/register')
.send({ email: '[email protected]', password: 'PasswordDiTest12345' })
.expect(201);
const res = await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'PasswordDiTest12345' })
.expect(200);
expect(res.body.message).toBe('Login riuscito');
});
test('rifiuta una password errata', async () => {
await request(app)
.post('/login')
.send({ email: '[email protected]', password: 'passwordSbagliata' })
.expect(401);
});
});
Sul fronte del logging, registra sempre i tentativi di login falliti (email, IP, timestamp) ma mai la password in chiaro, nemmeno nei log di debug: è uno degli errori più comuni che porta poi a violazioni quando i log stessi vengono compromessi. Prima del deploy in produzione, verifica questa checklist minima: parametri Argon2id calibrati sul hardware reale, pepper caricato da variabile d'ambiente e non hardcoded, rate limiting attivo su login e registrazione, HTTPS obbligatorio su tutti gli endpoint di autenticazione, e un piano di migrazione lazy attivo se il database contiene hash legacy.
Errori comuni da evitare
- Usare SHA-256 o MD5 direttamente sulle password. Sono funzioni veloci per design, pensate per l'integrità dei dati, non per proteggere segreti a bassa entropia come le password.
- Confrontare gli hash con l'operatore
===. Espone il confronto a timing attack. Usa sempreargon2.verify(),bcrypt.compare()ocrypto.timingSafeEqual()per i confronti manuali. - Salvare il pepper nello stesso database degli hash. Vanifica lo scopo del pepper: deve vivere in un sistema separato, come una variabile d'ambiente gestita da un secret manager.
- Ignorare il limite di 72 byte di bcrypt. Password lunghe vengono troncate senza errore, creando collisioni silenziose tra password diverse.
- Forzare il reset di massa delle password durante una migrazione. Genera un carico di supporto enorme e spinge molti utenti ad abbandonare l'account. La migrazione lazy al primo login è quasi sempre preferibile.
- Non limitare i tentativi di login. Anche il miglior algoritmo di hashing non rallenta un attaccante che può provare all'infinito senza restrizioni sul numero di richieste.
- Loggare la password in chiaro per debug. Un log applicativo finito su un bucket mal configurato o su un servizio di terze parti diventa una fonte di credenziali in chiaro, vanificando ogni sforzo fatto sull'hashing.
Risoluzione dei problemi più comuni
Anche seguendo la guida passo passo, capita di incontrare errori durante l'installazione o l'esecuzione. Ecco le situazioni più frequenti riscontrate con argon2 e bcrypt in Node.js, raccolte da problemi tipici segnalati nelle issue dei due progetti open source e da esperienze dirette di integrazione in ambienti di produzione con Express.
| Problema | Causa probabile | Soluzione |
|---|---|---|
Errore durante npm install argon2 su Linux | Mancano i tool di build (gcc, make, python) | Installa il pacchetto build-essential (Debian/Ubuntu) prima di reinstallare |
| argon2.verify() restituisce sempre false | Pepper diverso tra hashing e verifica | Controlla che la variabile d'ambiente PASSWORD_PEPPER sia identica in tutti gli ambienti |
| Hashing troppo lento sotto carico | memoryCost o timeCost troppo alti per l'hardware | Riduci memoryCost mantenendo il minimo OWASP di 19 MiB e ricalibra su hardware reale |
| bcrypt tronca password lunghe senza avviso | Limite intrinseco di 72 byte dell'algoritmo | Pre-hasha con SHA-256 prima di bcrypt o passa ad Argon2id |
| Timeout durante i test con Jest | timeCost troppo alto rallenta i test in CI | Usa parametri Argon2id ridotti (ma solo nell'ambiente di test) |
| Login lento percepito dagli utenti | Parametri di hashing troppo aggressivi | Calibra per un tempo di hashing tra 250 e 500 millisecondi |
| Utenti con hash MD5 non riescono più ad accedere dopo la migrazione | Codice di migrazione lazy non gestisce correttamente il formato legacy | Verifica la lunghezza e il formato dell'hash prima di scegliere l'algoritmo di verifica |
| express-rate-limit blocca utenti legittimi dietro un proxy aziendale | Tutte le richieste arrivano dallo stesso IP del proxy | Combina il rate limiting per IP con un limite separato per account |
| Modulo argon2 non trovato dopo il deploy | node_modules non ricompilato per l'architettura del server di produzione | Esegui npm install direttamente sul server o nella pipeline CI/CD per la piattaforma target |
Argon2id vs bcrypt vs PBKDF2: tabella comparativa e consigli avanzati
Ogni algoritmo ha un caso d'uso specifico. Ecco un confronto rapido basato sui parametri minimi raccomandati da OWASP.
| Algoritmo | Parametri minimi OWASP | Quando usarlo |
|---|---|---|
| Argon2id | 19 MiB memoria, 2 iterazioni, parallelismo 1 | Scelta di default per nuovi progetti, resistente a GPU e ASIC |
| scrypt | Costo CPU/memoria 2^17, blocco 8, parallelismo 1 | Alternativa se Argon2id non è disponibile nell'ambiente |
| bcrypt | Work factor 10 o superiore, limite 72 byte | Sistemi legacy o ambienti senza supporto per moduli nativi complessi |
| PBKDF2-HMAC-SHA256 | 600.000 iterazioni o più | Richiesto per conformità FIPS-140 |
| SHA-256 semplice (anti-pattern) | Nessun parametro di costo configurabile | Da evitare sempre: troppo veloce, permette miliardi di tentativi al secondo su GPU |
Per i team che gestiscono più applicazioni, alcuni consigli avanzati aiutano a ridurre il rischio nel lungo periodo. Primo: automatizza l'upgrade del work factor. Quando l'hardware disponibile cresce, il work factor calibrato oggi diventa insufficiente tra due o tre anni. Alla prossima verifica riuscita, se rilevi che i parametri salvati insieme all'hash sono inferiori a quelli correnti, rigenera l'hash con i parametri aggiornati, esattamente come fai per la migrazione da MD5 ad Argon2id.
Secondo: separa fisicamente il servizio di autenticazione dal resto dell'applicazione, se la scala del progetto lo giustifica. Un microservizio dedicato all'hashing riduce la superficie di attacco e semplifica l'auditing. Terzo: monitora la distribuzione dei tempi di risposta dell'endpoint di login. Un aumento anomalo di richieste con tempi di risposta uniformemente bassi può indicare un bypass del layer di hashing, ad esempio un endpoint di debug dimenticato in produzione.
Quarto: tieni traccia delle versioni delle librerie di hashing che usi, non solo del codice applicativo. Un aggiornamento di argon2 o bcrypt può cambiare il comportamento di default o correggere una vulnerabilità nel binding nativo, ed è facile perdersi questi aggiornamenti se il pacchetto non compare mai nei log delle dipendenze critiche. Includi argon2 e bcrypt nella lista dei pacchetti da controllare prioritariamente in ogni scansione delle dipendenze, insieme alle altre librerie che maneggiano segreti come i client JWT o le librerie TLS.
Quinto, e spesso sottovalutato: documenta la procedura di rotazione del pepper e testala almeno una volta in un ambiente di staging prima di averne davvero bisogno. Un incidente di sicurezza che richiede una rotazione d'emergenza non è il momento migliore per scoprire che lo script di rotazione non è mai stato eseguito con successo. Simula lo scenario, misura quanto tempo richiede la rotazione su un dataset di dimensioni simili a quello di produzione, e conserva la procedura aggiornata insieme al resto della documentazione operativa del team.
Infine, se la tua superficie di attacco include anche API pubbliche oltre al login web, rivedi la sezione sull'autenticazione della nostra guida OWASP Top 10 applicata a Node.js, che copre anche i controlli di accesso complementari all'hashing delle password.
Monitoraggio e audit continuo dopo il lancio
Il lavoro non finisce quando il codice va in produzione. L'hashing delle password è uno di quei sottosistemi che vanno rivisti periodicamente, non solo scritti una volta e dimenticati. Pianifica una verifica trimestrale di tre cose: i parametri di Argon2id sono ancora adeguati rispetto all'hardware disponibile oggi, la percentuale di utenti ancora su hash legacy si sta riducendo come previsto, e i log di rate limiting non mostrano pattern anomali che suggeriscano un attacco in corso non ancora bloccato.
Un buon indicatore da tracciare in un dashboard è la distribuzione dei tempi di risposta dell'endpoint /login, separata per esito (successo, credenziali errate, account bloccato). Se il tempo medio per le credenziali errate scende sensibilmente sotto quello per il successo, probabilmente il confronto timing-safe si è rotto in qualche punto del codice, magari dopo una modifica successiva che ha reintrodotto un controllo === da qualche parte nel flusso. Vale la pena anche impostare un avviso quando il numero di conversioni lazy da MD5/SHA1 ad Argon2id supera una soglia oraria anomala: potrebbe indicare che un attaccante sta usando credenziali rubate altrove per validare quali account sul tuo sistema condividono la stessa password (credential stuffing), sfruttando proprio il momento della migrazione per accedere prima che l'utente cambi password.
Hashing delle password in ambienti serverless
Se distribuisci l'endpoint di autenticazione su una piattaforma serverless come AWS Lambda o Azure Functions, ci sono due complicazioni in più rispetto a un server Express tradizionale sempre acceso. La prima riguarda il cold start: il modulo argon2 nativo va ricompilato o comunque caricato ad ogni nuova istanza della funzione, e questo aggiunge latenza alla prima richiesta dopo un periodo di inattività. La seconda riguarda i limiti di memoria e CPU imposti dalla piattaforma, che possono rendere impraticabili i parametri Argon2id calibrati per un server dedicato.
In pratica, se scegli la strada serverless, verifica che il pacchetto di deploy includa il binario nativo compilato per l'architettura corretta (x86_64 o ARM64, a seconda della piattaforma) e non solo il sorgente. Molti team preferiscono isolare la logica di hashing in un servizio dedicato sempre attivo, magari un piccolo container su un'istanza economica, proprio per evitare la variabilità di prestazioni introdotta dai cold start su un'operazione che deve restare entro i 250-500 millisecondi indicati in precedenza. Se il tuo carico di autenticazione è imprevedibile o a bassissimo volume, il costo aggiuntivo di un servizio sempre attivo è comunque contenuto rispetto al rischio di un'esperienza utente incoerente causata da cold start intermittenti.
Il progetto completo: struttura finale e cosa fare dopo
Mettendo insieme tutti i passi, la struttura finale del progetto è questa:
password-hashing-demo/
├── config/
│ └── argon2.js
├── routes/
│ ├── register.js
│ └── login.js
├── __tests__/
│ └── auth.test.js
├── db.js
├── app.js
├── .env
└── package.json
Il file app.js collega tutto: carica le variabili d'ambiente con dotenv, monta le route di registrazione e login, applica il rate limiter e avvia il server Express sulla porta configurata.
// app.js
require('dotenv').config();
const express = require('express');
const registerRoute = require('./routes/register');
const loginRoute = require('./routes/login');
if (!process.env.PASSWORD_PEPPER) {
throw new Error('PASSWORD_PEPPER mancante: imposta la variabile prima di avviare il server');
}
const app = express();
app.use(express.json());
app.use('/', registerRoute);
app.use('/', loginRoute);
if (require.main === module) {
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server avviato sulla porta ${port}`));
}
module.exports = app;
Da qui puoi estendere il progetto con un vero database (Postgres o MongoDB al posto del mock db.js), un sistema di reset password via email con token a scadenza breve, e l'integrazione del secondo fattore descritta nella guida sul TOTP. Se gestisci anche sessioni o token OAuth lato client, la nostra guida su OAuth 2.0 e PKCE in Node.js copre la parte successiva del flusso di autenticazione, una volta che la password è stata verificata correttamente.
Il codice che hai costruito in questi 12 passi copre il nucleo di sicurezza più critico: hashing con parametri conformi a OWASP, pepper separato dal database, migrazione trasparente degli hash legacy, protezione dai timing attack e rate limiting sui tentativi di accesso. È lo stesso schema, con parametri diversi, che puoi applicare a qualsiasi stack che supporti binding nativi o implementazioni pure di Argon2id.
Domande frequenti
Argon2id è davvero meglio di bcrypt?
Per i nuovi progetti sì, secondo le raccomandazioni attuali di OWASP. Argon2id resiste meglio agli attacchi con GPU e hardware dedicato grazie al costo di memoria configurabile, un parametro che bcrypt non offre. Bcrypt resta comunque una scelta accettabile per sistemi legacy o ambienti con vincoli tecnici sui moduli nativi.
Quanto deve essere lungo il pepper?
Almeno 32 byte generati con un generatore crittograficamente sicuro come crypto.randomBytes(). Deve essere unico per ambiente (sviluppo, staging, produzione) e non deve mai essere incluso nel repository del codice.
Devo forzare tutti gli utenti a resettare la password quando cambio algoritmo?
Nella maggior parte dei casi no. La migrazione lazy, che converte l'hash al primo login riuscito, evita l'attrito di un reset di massa mantenendo comunque la sicurezza per gli account attivi.
argon2 o bcrypt: quale scegliere se il mio hosting non permette moduli nativi?
In quel caso bcryptjs, un'implementazione pura JavaScript di bcrypt, evita il problema della compilazione nativa, al costo di prestazioni inferiori rispetto al binding nativo.
Quanto tempo deve richiedere l'hashing di una password?
Un intervallo comune è tra 250 e 500 millisecondi su hardware di produzione. Valori più bassi riducono la resistenza al brute force, valori più alti degradano l'esperienza utente e possono saturare la CPU sotto carico elevato.
Il rate limiting basta da solo a proteggere il login?
No. Va combinato con un algoritmo di hashing robusto, un blocco account dopo tentativi falliti ripetuti e, quando possibile, un secondo fattore di autenticazione.
Cosa succede se dimentico di impostare la variabile PASSWORD_PEPPER in produzione?
Il modulo crypto genererà un errore alla chiamata di createHmac con una chiave undefined. È buona pratica far fallire l'avvio dell'applicazione con un controllo esplicito se questa variabile manca, piuttosto che lasciarla opzionale.
L'hashing delle password rientra negli obblighi del GDPR?
Sì, indirettamente. L'articolo 32 del GDPR richiede misure tecniche adeguate al rischio per proteggere i dati personali, e le password sono tra i dati più sensibili da questo punto di vista. Un hashing debole può pesare negativamente in caso di valutazione da parte di un'autorità di controllo dopo una violazione dei dati.




