# Autenticazione sicura 2026: password, Argon2, sessioni e JWT

> Una guida pratica su come implementare un sistema di autenticazione sicuro nel 2026: dalla scelta delle password all'hashing con Argon2, dai cookie ai JWT, fino al 2FA e alle passkey. Con esempi di codice in PHP e Python.

- URL: https://mautoblog.com/posts/autenticazione-sicura-password-sessioni-jwt-guida-completa-2026/
- Lingua: it
- Pubblicato: 2026-03-14
- Aggiornato: 2026-10-16
- Autore: Matteo
- Categorie: Sicurezza e Privacy, Sviluppo Software
- Tags: Crittografia, Cybersecurity, Privacy, PHP, Python
- Traduzione (en): https://mautoblog.com/en/posts/secure-authentication-passwords-sessions-jwt-complete-guide-2026/

---

In questi giorni ho avuto il piacere — si fa per dire — di immergermi fino al collo nei sistemi di autenticazione. Login, registrazione, gestione delle sessioni, reset password: uno di quei lavori che in azienda vengono spesso sottovalutati perché "in fondo lo ha già fatto qualcuno prima di noi", salvo poi scoprire che quel qualcuno aveva idee molto creative sulla sicurezza.

Aprivo Stack Overflow per un riferimento rapido e trovavo risposte del 2012 con MD5 presentato come soluzione valida. Guardavo il codice legacy su cui innestare le nuove funzionalità e trovavo password salvate in chiaro con un commento `// TODO: aggiungere l'hash`. Il TODO era del 2011. Quindici anni dopo, quelle password erano ancora lì, nude e felici, nel database di produzione.

Risultato: invece di imprecare in silenzio, ho deciso di scrivere tutto quello che ho rimesso a fuoco in questi giorni. Così la prossima volta mando il link.

## Il problema con le password "complesse"

Partiamo da un assunto che sembra controintuitivo: le regole classiche per le password sono quasi inutili.

Quella roba del tipo "usa almeno una maiuscola, un numero, un simbolo e il sangue di un unicorno certificato" non ti protegge quanto pensi. Perché? Perché mette l'utente con le spalle al muro, e lui risponde nel modo più umano possibile: scrive `P@ssw0rd1` su un post-it attaccato al monitor, oppure riusa la stessa password dappertutto.

Risultato: una password che sembra forte ma è di fatto inutile.

**Cosa funziona davvero?** La lunghezza. È più utile richiedere un minimo di caratteri che imporre acrobazie di simboli. Le linee guida del NIST (SP 800-63B, revisione 4 del 2025) chiedono almeno **15 caratteri** quando la password è l'unico fattore di autenticazione, 8 quando c'è anche un secondo fattore, e di accettarne almeno 64 senza troncarli. Una passphrase come `treno-viola-lunedi-pizza` è statisticamente più robusta di `Tr3n0!` e infinitamente più facile da ricordare.

C'è poi un controllo che le stesse linee guida rendono obbligatorio: confrontare la password con una **lista di password comuni o già finite in qualche violazione**. Se c'è, la rifiuti. Non serve tenerla in casa: servizi come [Pwned Passwords](https://haveibeenpwned.com/Passwords) si interrogano mandando solo i primi 5 caratteri dell'hash SHA-1 (k-anonymity), quindi la password non lascia mai il tuo server.

L'altro aspetto da controllare è la **prevedibilità strutturale**. Una password come `12345678` o `qwertyui` rispetta tranquillamente un limite di 8 caratteri, eppure fa schifo lo stesso. Come lo rilevi? Con un paio di tecniche interessanti.

La prima è la **compressione run-length**: se provi a comprimere la password con RLE e il risultato è brevissimo, significa che è composta da caratteri ripetuti (`aaaaaaaa` → `a8`). Pessimo segno.

La seconda è l'analisi della **derivata prima**: si calcola la differenza assoluta tra i valori ASCII di caratteri consecutivi e si verifica se anche questa sequenza è comprimibile. Se la password è `ABCDEF` o `12345678`, le differenze sono tutte `1`, e la derivata è ultra-comprimibile. In questo modo intercetti sequenze prevedibili anche quando non sono caratteri identici. Non funziona però con i percorsi da tastiera come `qwertyui` o `asdfgh`: l'ordine dei tasti non è l'ordine ASCII, e le differenze escono sparse. Per quelli serve un dizionario di schemi, come fa la libreria [zxcvbn](https://github.com/dropbox/zxcvbn), oppure la lista di password comuni di cui sopra, dove `qwerty` e simili ci sono di sicuro.

In pratica, invece di bloccare l'utente con regole cervellotiche, puoi dargli un feedback utile: *"questa password è troppo prevedibile, prova qualcosa di più casuale"*. Le password presenti nella lista vanno rifiutate; per quelle solo "deboli" puoi decidere se bloccare o limitarti ad avvisare. L'importante è che tu sappia.

## Hashing: mai salvare le password in chiaro

Le password non si salvano. Mai. Si trasformano tramite una funzione di **hashing**, un processo non reversibile che produce un'impronta di lunghezza fissa da cui non si può risalire alla password. Anche se qualcuno ti ruba il database, non vede le password originali — vede solo gli hash.

Ma attenzione: non tutti gli algoritmi di hashing sono adatti allo scopo. Gli algoritmi veloci come SHA-1 o MD5 sono stati progettati per controllare l'integrità dei file, non per proteggere le password. Sono così rapidi che con un cluster di GPU moderne si possono testare miliardi di combinazioni al secondo.

Per le password servono algoritmi **intenzionalmente lenti**, progettati per far costare caro qualsiasi tentativo di brute force.

### Bcrypt: il vecchio affidabile

Per anni Bcrypt è stato lo standard de facto. Funziona ancora — OWASP lo considera accettabile per i sistemi esistenti, con un cost di almeno 10 (da PHP 8.4 il default è 12) — ma ha due limiti.

Il primo: è lento sulla CPU, ma non richiede molta memoria. Chi attacca con molte GPU o con hardware dedicato riesce a parallelizzare meglio che con Argon2. E nessun cost salva una password banale: quelle cadono comunque in pochi tentativi.

Il secondo, spesso ignorato: Bcrypt considera solo i primi **72 byte** della password. PHP tronca il resto in silenzio, la libreria Python `bcrypt` dalla versione 5.0 solleva un `ValueError`. Con le passphrase lunghe e i caratteri accentati (che in UTF-8 occupano più byte) è un limite da conoscere.

```php
<?php
// PHP - Hashing con Bcrypt (attenzione: conta solo i primi 72 byte)
$password = 'la_password_dellutente';
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verifica
if (password_verify($password, $hash)) {
    echo "Password corretta!";
}
```

```python
# Python - Hashing con Bcrypt (oltre 72 byte, dalla 5.0: ValueError)
import bcrypt

password = b'la_password_dellutente'
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password, salt)

# Verifica
if bcrypt.checkpw(password, hashed):
    print("Password corretta!")
```

### Argon2: il gold standard attuale

Argon2 ha vinto la Password Hashing Competition nel 2015 ed è oggi considerato il miglior algoritmo disponibile; la variante da usare per le password è **Argon2id**. La differenza chiave rispetto a Bcrypt è il **memory-hardness**: puoi configurarlo per richiedere molta memoria per calcolare ogni singolo hash.

Le GPU hanno tantissima potenza di calcolo parallelo, ma la memoria per ogni singolo calcolo è il loro collo di bottiglia: più memoria richiede ogni hash, meno tentativi in parallelo può fare un attaccante. Su un server web però non puoi esagerare, perché ogni login in corso occupa quella memoria: con qualche gigabyte per hash bastano pochi login contemporanei per mettere in ginocchio il tuo stesso server. Valori tipici vanno da 19 MiB (il minimo indicato da OWASP: m=19 MiB, t=2, p=1) a 64 MiB (il profilo consigliato dalla RFC 9106 per chi ha poca memoria: t=3, p=4).

I parametri configurabili sono tre:
- **Memoria**: quanta RAM richiede ogni calcolo
- **Iterazioni**: quante volte ripete il processo
- **Parallelismo**: quanti core della CPU può usare

L'obiettivo pratico è che ogni autenticazione richieda tra i 100 e i 500 millisecondi sul tuo server. Per l'utente è impercettibile; per un attaccante che deve provare milioni di combinazioni, moltiplica il costo di ogni tentativo. Non salva però una password banale: `password123` cade comunque al primo giro di dizionario, ed è per questo che serve anche la lista di password comuni.

```php
<?php
// PHP - Hashing con Argon2id (disponibile da PHP 7.3)
$password = 'la_password_dellutente';
$opzioni = [
    'memory_cost' => 65536, // 64 MiB
    'time_cost'   => 4,     // 4 iterazioni
    'threads'     => 1,     // >1 solo se PHP usa libargon2: con libsodium dà errore
];
$hash = password_hash($password, PASSWORD_ARGON2ID, $opzioni);

// Verifica (API identica a Bcrypt)
if (password_verify($password, $hash)) {
    echo "Password corretta!";
    // Se nel frattempo hai cambiato algoritmo o parametri, aggiorna l'hash
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID, $opzioni)) {
        $hash = password_hash($password, PASSWORD_ARGON2ID, $opzioni); // e salvalo nel DB
    }
}
```

```python
# Python - Hashing con Argon2id (pip install argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()  # default: profilo RFC 9106 a bassa memoria (64 MiB, t=3, p=4)

hashed = ph.hash('la_password_dellutente')

# Verifica
try:
    ph.verify(hashed, 'la_password_dellutente')
    print("Password corretta!")
    if ph.check_needs_rehash(hashed):  # parametri cambiati: aggiorna l'hash nel DB
        hashed = ph.hash('la_password_dellutente')
except VerifyMismatchError:
    print("Password errata.")
```

Nota: si intercetta solo `VerifyMismatchError`, non `Exception`. Un hash corrotto o un errore di configurazione devono emergere, non diventare un generico "password errata".

## Il Salt: perché ogni hash deve essere unico

Anche con Argon2, c'è un dettaglio imprescindibile: il **salt**. È una stringa casuale generata da un CSPRNG (*Cryptographically Secure Pseudo-Random Number Generator*) — in PHP `random_bytes()`, in Python il modulo `secrets`, che a loro volta usano `getrandom()` o `/dev/urandom` del sistema operativo — e generata di nuovo per ogni hash, quindi anche ogni volta che l'utente cambia password.

Il salt serve a due cose fondamentali.

**Prima cosa**: se due utenti usano la stessa password, i loro hash nel database saranno comunque diversi. Senza salt, un attaccante che vede due hash identici capisce immediatamente che quelle due persone hanno la stessa password — e probabilmente una comune, facile da indovinare.

**Seconda cosa**: rende inutili gli attacchi con rainbow table. Una rainbow table è un dizionario precalcolato di hash per le password più comuni. Con il salt, l'attaccante dovrebbe rifare i calcoli separatamente per ogni singolo record del database, usando il salt specifico di quell'hash.

La buona notizia è che sia `password_hash()` in PHP che le librerie Argon2/Bcrypt in Python gestiscono il salt automaticamente. Non devi calcolarlo tu: è già incorporato nell'hash risultante.

```php
<?php
// Il salt è gestito automaticamente e incorporato nell'hash
$hash = password_hash('mia_password', PASSWORD_ARGON2ID);
// $hash contiene: algoritmo + parametri + salt + hash
// Esempio (default di PHP): $argon2id$v=19$m=65536,t=4,p=1$<salt_base64>$<hash_base64>
```

## Session ID e Cookie: gestire la sessione dopo il login

Una volta che l'utente si è autenticato con successo, devi mantenerlo tale mentre naviga. Qui entra in gioco la **sessione**.

Il funzionamento è semplice: il server genera un ID di sessione univoco, lo associa all'utente nel database e lo manda al browser tramite un cookie. Ad ogni richiesta successiva, il browser rimanda il cookie e il server verifica che l'ID sia valido.

### Generare un session ID sicuro

L'ID di sessione deve essere **assolutamente imprevedibile**. Deve avere almeno 128 bit di entropia, generati da un CSPRNG. Se fosse prevedibile, un attaccante potrebbe indovinarlo e prendere il controllo della sessione di un altro utente.

E deve essere **nuovo a ogni login**: mai riusare o "promuovere" un ID che esisteva prima del login, altrimenti un attaccante può farti usare un ID che conosce già (*session fixation*). Lo stesso vale dopo ogni cambio di privilegi, compreso il superamento del 2FA. Se usi le sessioni native di PHP invece di una tabella tua, la funzione è `session_regenerate_id(true)`.

Nel database, poi, conviene salvare solo l'**hash** dell'ID, come vedremo per i token di reset: se qualcuno legge la tabella (un dump, una SQL injection), non trova sessioni ancora valide da riusare.

```php
<?php
// PHP - Generazione di un session ID sicuro
$session_id   = bin2hex(random_bytes(32));     // 256 bit → 64 caratteri hex, va nel cookie
$session_hash = hash('sha256', $session_id);   // nel database salvi solo questo

$stmt = $pdo->prepare("
    INSERT INTO sessions (session_hash, user_id, created_at, last_activity)
    VALUES (?, ?, NOW(), NOW())
");
$stmt->execute([$session_hash, $user_id]);
```

```python
# Python - Generazione di un session ID sicuro
import datetime
import hashlib
import secrets

session_id = secrets.token_hex(32)  # 256 bit, va nel cookie
session_hash = hashlib.sha256(session_id.encode()).hexdigest()  # nel database solo questo
adesso = datetime.datetime.now(datetime.timezone.utc)

# Salvataggio nel database (esempio con SQLAlchemy)
new_session = Session(
    session_hash=session_hash,
    user_id=user_id,
    created_at=adesso,
    last_activity=adesso
)
db.add(new_session)
db.commit()
```

### Proteggere il cookie di sessione

Una volta generato l'ID, mandarlo al browser con i flag giusti è fondamentale:

```php
<?php
setcookie('session_id', $session_id, [
    'expires'  => time() + 86400, // 24 ore
    'path'     => '/',
    'secure'   => true,       // Solo su HTTPS
    'httponly' => true,       // Non leggibile da JavaScript (limita i danni di un XSS)
    'samesite' => 'Lax',      // Mitiga il CSRF (non sostituisce i token CSRF)
]);
```

- **HttpOnly** impedisce a JavaScript di leggere il cookie. Se un attaccante riesce a iniettare del codice JS nella pagina (attacco XSS), non può portarsi via il cookie; può però agire come l'utente finché la pagina compromessa è aperta. Limita i danni, non sostituisce la prevenzione dell'XSS.
- **Secure** fa sì che il cookie viaggi solo su HTTPS. Senza questo flag, il cookie vola in chiaro sulle reti non cifrate.
- **SameSite=Lax** non invia il cookie sulle richieste che arrivano da altri siti, tranne le navigazioni top-level con metodi "sicuri" come GET (un clic su un link, ma anche un form in GET o un redirect). Riduce molto il rischio CSRF ma non lo elimina: niente modifiche di stato via GET, e sulle richieste POST un token CSRF o almeno la verifica dell'header `Origin`. Inoltre "sito" vuol dire dominio registrato, non origine: un sottodominio compromesso conta come stesso sito.

### Gestire la scadenza della sessione

Una sessione non deve durare in eterno. Devi gestire due tipi di timeout:

- **Tempo assoluto**: la sessione scade dopo X ore dal login, anche se l'utente è attivo
- **Timeout di inattività**: la sessione scade se l'utente non fa nulla per Y minuti

Quando una sessione scade — o l'utente fa logout — **devi cancellarla dal database**, non solo dal browser. Altrimenti chiunque abbia intercettato o rubato il cookie può continuare a usarlo indisturbato.

```php
<?php
// Logout: cancella la sessione dal database E dal browser
function logout(PDO $pdo, string $session_id): void
{
    $stmt = $pdo->prepare("DELETE FROM sessions WHERE session_hash = ?");
    $stmt->execute([hash('sha256', $session_id)]);

    setcookie('session_id', '', [
        'expires'  => time() - 3600,
        'path'     => '/',
        'secure'   => true,
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

// Reset password: invalida TUTTE le sessioni attive dell'utente
function invalidate_all_sessions(PDO $pdo, int $user_id): void
{
    $stmt = $pdo->prepare("DELETE FROM sessions WHERE user_id = ?");
    $stmt->execute([$user_id]);
}
```

## Sessione sul server o JWT: quale scegliere?

I **JSON Web Token** sono ovunque e vengono spesso presentati come la soluzione moderna per tutto. La realtà è più sfumata. E una precisazione: un JWT può benissimo viaggiare dentro un cookie, quindi la vera scelta non è "cookie o JWT", ma **sessione salvata sul server** (il cookie contiene solo un ID) o **token autocontenuto** (il token contiene già i dati).

Un JWT è un token firmato — con una firma digitale come RS256 o ES256, o con un HMAC a chiave condivisa come HS256 — che contiene già i dati dell'utente nel payload. Il server non deve fare una query al database per sapere chi sei: verifica la firma e legge il payload. Questo è il suo grande vantaggio nelle architetture distribuite.

Attenzione però: il payload è solo codificato in base64url, **non cifrato**. Chiunque abbia il token può leggerlo, quindi niente dati sensibili lì dentro. E in verifica non fidarti mai di quello che il token dice di sé: fissa tu l'algoritmo (così eviti `alg: none` e gli attacchi di confusione tra algoritmi) e controlla emittente, destinatario e scadenza, come indica la RFC 8725.

```python
# Python - Verifica di un JWT con PyJWT (pip install pyjwt)
import jwt

payload = jwt.decode(
    token,
    chiave_pubblica,
    algorithms=["RS256"],  # algoritmo fissato da te, mai letto dal token
    audience="api.tuosito.it",
    issuer="https://auth.tuosito.it",
    options={"require": ["exp", "iss", "aud", "sub"]},
)
```

Il problema dei JWT per le sessioni utente su browser è che **non si possono revocare facilmente**. Se un token ha una validità di 24 ore e l'utente fa logout o cambia password, quel token esiste ancora e funziona fino alla scadenza naturale. A meno che tu non implementi una blacklist, che a quel punto ti riporta a fare query al database e perdi il vantaggio principale. La risposta standard è un **access token di breve durata** (5-15 minuti) affiancato da un **refresh token** che viene sostituito a ogni uso: se qualcuno prova a riusarne uno vecchio, il server se ne accorge e invalida tutta la catena.

La regola pratica è questa:

| Scenario | Soluzione consigliata |
|---|---|
| Sessioni utente su browser | Cookie di sessione + database |
| Autenticazione tra API e servizi | JWT di breve durata |
| App mobile che chiama una tua API | Access token brevi + refresh token ruotati |
| Single Page Application con backend separato | Cookie di sessione HttpOnly gestito dal backend (pattern BFF); se servono token, access token brevi + refresh token ruotati, mai in `localStorage` |

Per la maggior parte delle applicazioni web "classiche" — backend PHP o Python che renderizzano HTML — i cookie di sessione sono la scelta più semplice, efficiente e sicura. I JWT brillano invece con microservizi, API pubbliche o architetture in cui più server devono validare le richieste senza coordinarsi.

## Il reset password: un vettore di attacco sottovalutato

Implementare il reset tramite email significa che l'accesso alla casella di posta dell'utente equivale all'accesso al tuo servizio. Se l'email è compromessa, tutti i tuoi meccanismi con Argon2 e cookie sicuri diventano carta straccia: l'attaccante chiede semplicemente un link di reset.

Questo non significa che non devi implementarlo — è necessario — ma va trattato con la stessa serietà del login primario:

- Il link di reset deve avere una scadenza breve (15-30 minuti al massimo)
- Il token nel link deve essere generato con un CSPRNG, non con `rand()` o `microtime()`
- Dopo l'uso, il token deve essere invalidato immediatamente
- Il reset deve invalidare tutte le sessioni attive dell'utente
- Il form risponde sempre allo stesso modo (*"se l'indirizzo è registrato, riceverai un'email"*), che l'email esista o no: altrimenti diventa un modo comodo per scoprire chi è iscritto
- La pagina di reset manda l'header `Referrer-Policy: no-referrer`, così il token nell'URL non finisce nei Referer verso siti esterni

```php
<?php
// Token di reset sicuro: inviato in chiaro nell'URL, salvato hashato nel DB
// Così anche un dump del database non espone i token ancora validi
$reset_token = bin2hex(random_bytes(32));
$token_hash  = hash('sha256', $reset_token);

// Un solo token valido per utente: cancella il precedente e inserisci il nuovo.
// La scadenza la calcola il database, come NOW() nelle sessioni: niente fusi orari diversi tra PHP e DB
$pdo->prepare("DELETE FROM password_resets WHERE user_id = ?")->execute([$user_id]);
$stmt = $pdo->prepare("
    INSERT INTO password_resets (user_id, token_hash, expires_at)
    VALUES (?, ?, NOW() + INTERVAL 30 MINUTE)
");
$stmt->execute([$user_id, $token_hash]);

$reset_url = "https://tuosito.it/reset-password?token={$reset_token}";
// Manda $reset_url via email all'utente
```

## Autenticazione a Due Fattori (2FA): il secondo lucchetto

Il 2FA aggiunge un secondo livello di verifica basato sul **possesso** di qualcosa, non solo sulla conoscenza di qualcosa. Anche se l'attaccante conosce la password, deve avere anche il dispositivo fisico dell'utente.

Le opzioni principali:

**Passkey (WebAuthn/FIDO2)**: la chiave privata resta sul dispositivo o nel portachiavi del sistema (iCloud, Google, un password manager) ed è legata al dominio del sito, quindi una pagina di phishing non può usarla. Sono supportate da tutti i principali sistemi operativi e browser, sono l'opzione più sicura disponibile su larga scala e possono anche sostituire del tutto la password. Le linee guida NIST chiedono che, per il livello di sicurezza tipico di un account con 2FA, ci sia almeno un'opzione resistente al phishing come questa.

**Chiavi hardware (YubiKey e simili)**: dispositivi fisici USB o NFC basati sullo stesso standard FIDO2. Sicurissimi e resistenti al phishing, ma diffusi soprattutto in azienda o tra utenti particolarmente attenti alla sicurezza.

**App di autenticazione (TOTP)**: Google Authenticator, Authy e simili generano codici temporanei basati su un segreto condiviso via QR code. È un buon compromesso e molto diffuso, ma non resiste al phishing in tempo reale: una pagina finta può chiederti il codice e inoltrarlo subito al sito vero.

**SMS**: è il metodo più diffuso e comprensibile per gli utenti, ma anche il più debole. Esiste il **SIM swap** — convincere l'operatore a trasferire il tuo numero su una SIM dell'attaccante, un attacco di social engineering economico e diffuso — oltre al phishing in tempo reale. Il NIST lo classifica come metodo "ristretto": va bene come ripiego, non come unica opzione. Resta comunque meglio di niente.

```python
# Python - TOTP con pyotp (pip install pyotp)
import datetime
import pyotp

# Generazione del segreto: va salvato cifrato nel database
# (non si può hashare: serve in chiaro per verificare i codici)
secret = pyotp.random_base32()

# URI per configurare Google Authenticator (da convertire in QR code)
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
    name="matteo@example.com",
    issuer_name="Il Mio Servizio"
)

# Verifica del codice inserito dall'utente
codice_utente = "123456"
adesso = datetime.datetime.now(datetime.timezone.utc)
passo = totp.timecode(adesso)  # numero della finestra di 30 secondi corrente

if utente.ultimo_passo_totp is not None and passo <= utente.ultimo_passo_totp:
    print("Codice già usato, aspetta il prossimo.")  # anti-replay (RFC 6238)
elif totp.verify(codice_utente, for_time=adesso, valid_window=1):  # tollera ±30 s di orologio
    utente.ultimo_passo_totp = passo  # da salvare nel database
    print("Codice valido, accesso consentito!")
else:
    print("Codice non valido.")  # e conta i tentativi: dopo pochi errori, blocca per un po'
```

Il codice controlla tre cose che la versione "minima" di solito dimentica: lo stesso codice non si può usare due volte (la RFC 6238 lo vieta), un orologio un po' sfasato non blocca l'utente, e i tentativi vanno limitati, altrimenti un milione di combinazioni si provano in fretta.

Due dettagli che spesso si dimenticano. Il primo: tra la password corretta e il codice OTP l'utente non ha ancora una sessione vera. Tieni uno stato temporaneo "password ok, manca il secondo fattore", con scadenza di pochi minuti e un limite ai tentativi, e crea la sessione (con un ID nuovo) solo dopo il secondo fattore. Il secondo: la procedura di reset password deve richiedere la verifica del 2FA, se attivo sull'account. Altrimenti hai un bel secondo fattore sull'ingresso principale e una porta sul retro completamente spalancata.

## Mettere tutto insieme: il flusso completo

Ricapitolando, un sistema di autenticazione ragionevole nel 2026 funziona così:

1. **Registrazione**: l'utente sceglie una password → la verifichi (almeno 15 caratteri, o 8 se c'è il 2FA; non presente nella lista di password comuni o violate; non banalmente prevedibile) → la hashi con Argon2id (salt incluso) → salvi solo l'hash nel database. Mai la password in chiaro.

2. **Login**: l'utente invia username e password → recuperi l'hash dal database → verifichi con `password_verify()` (e, se hai cambiato parametri, aggiorni l'hash con `password_needs_rehash()`) → se valido, generi un session ID **nuovo** con CSPRNG (128+ bit, mai quello di prima del login), salvi nel database il suo hash con la scadenza, lo mandi come cookie HttpOnly + Secure + SameSite.

3. **Sessione**: ad ogni richiesta, il browser manda il cookie → calcoli l'hash dell'ID e verifichi che esista nel database e non sia scaduto → aggiorni il timestamp di `last_activity`.

4. **Logout**: cancelli la sessione dal database e svuoti il cookie.

5. **Reset password**: generi un token sicuro con scadenza breve → lo mandi via email (nel DB solo il suo hash), rispondendo allo stesso modo che l'email esista o no → al click sul link verifichi il token e, se l'account ha il 2FA, anche il secondo fattore → cambi la password con un nuovo hash Argon2id e invalidi tutte le sessioni attive.

6. **2FA**: dopo la password corretta, chiedi il secondo fattore (meglio se una passkey o una chiave FIDO2) prima di creare la sessione. Con le passkey puoi anche fare a meno della password.

Non è rocket science, ma richiede attenzione ai dettagli. I dettagli che, evidentemente, qualcuno nel 2011 aveva rimandato a un TODO mai risolto.

---

*Nel prossimo episodio delle avventure da sviluppatore aziendale: legacy code, tabelle senza foreign key e quella sensazione di terrore misto a rassegnazione quando esegui `SHOW CREATE TABLE` e vedi `ENGINE=MyISAM`. Stay tuned.*

