Autenticazione Sicura nel 2026: Password, Hashing, Sessioni e JWT Spiegati Bene

Autenticazione sicura: password, hashing, sessioni e JWT
Perché la tua password fa schifo e come rimediare (senza impazzire)
Matteo 12 min

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 — oggi si punta a 10, 11 o 12 — che imporre acrobazie di simboli. Una passphrase come treno-viola-lunedi-pizza è statisticamente più robusta di Tr3n0! e infinitamente più facile da ricordare.

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 due 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 (aaaaaaaaa8). 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, le differenze sono tutte 1, e la derivata è ultra-comprimibile. Lo stesso vale per i percorsi da tastiera tipo asdfgh. In questo modo intercetti sequenze prevedibili anche quando non sono caratteri identici.

In pratica, invece di bloccare l’utente con regole cervellotiche, puoi dargli un feedback utile: “questa password è troppo prevedibile, prova qualcosa di più casuale”. Se poi vuoi lasciargli comunque la scelta di proseguire, è una decisione di prodotto. 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 una stringa binaria univoca. 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 costare cara qualsiasi tentativo di brute force.

Bcrypt: il vecchio affidabile

Per anni Bcrypt è stato lo standard de facto. Funziona ancora ed è meglio di niente, ma ha un limite: è progettato principalmente per essere lento sulla CPU, mentre le GPU moderne riescono comunque a parallelizzare gli attacchi in modo significativo. Con hardware sufficientemente potente, password fino a otto caratteri protette da Bcrypt possono essere violate.

<?php
// PHP - Hashing con Bcrypt
$password = 'la_password_dellutente';
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

// Verifica
if (password_verify($password, $hash)) {
    echo "Password corretta!";
}
# Python - Hashing con Bcrypt
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 una competizione internazionale di algoritmi di hashing nel 2015 ed è oggi considerato il miglior algoritmo disponibile. La differenza chiave rispetto a Bcrypt è il memory-hardness: puoi configurarlo per richiedere gigabyte di RAM per calcolare un singolo hash.

Le GPU hanno tantissima potenza di calcolo parallelo, ma gestiscono male l’accesso a grandi quantità di memoria per ogni singolo calcolo. Argon2 sfrutta esattamente questa debolezza: con 2 GB di RAM richiesti per hash, anche un cluster di GPU enormi rallenta drasticamente.

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, è un muro invalicabile.

<?php
// PHP - Hashing con Argon2id (disponibile da PHP 7.3)
$password = 'la_password_dellutente';
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // 64 MB
    'time_cost'   => 4,     // 4 iterazioni
    'threads'     => 2,     // 2 thread paralleli
]);

// Verifica (API identica a Bcrypt)
if (password_verify($password, $hash)) {
    echo "Password corretta!";
}
# Python - Hashing con Argon2 (pip install argon2-cffi)
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=4,
    memory_cost=65536,  # 64 MB
    parallelism=2
)

hashed = ph.hash('la_password_dellutente')

# Verifica
try:
    ph.verify(hashed, 'la_password_dellutente')
    print("Password corretta!")
except Exception:
    print("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 pratica, /dev/random sul sistema operativo — associata in modo univoco a ogni utente prima di calcolare l’hash.

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 impossibili 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’utente.

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
// Il salt è gestito automaticamente e incorporato nell'hash
$hash = password_hash('mia_password', PASSWORD_ARGON2ID);
// $hash contiene: algoritmo + parametri + salt + hash
// Esempio: $argon2id$v=19$m=65536,t=4,p=2$<salt_base64>$<hash_base64>

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 salva nel database associandolo all’utente, 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.

<?php
// PHP - Generazione di un session ID sicuro
$session_id = bin2hex(random_bytes(32)); // 256 bit → 64 caratteri hex

// Salvataggio nel database
$stmt = $pdo->prepare("
    INSERT INTO sessions (session_id, user_id, created_at, last_activity)
    VALUES (?, ?, NOW(), NOW())
");
$stmt->execute([$session_id, $user_id]);
# Python - Generazione di un session ID sicuro
import secrets
import datetime

session_id = secrets.token_hex(32)  # 256 bit

# Salvataggio nel database (esempio con SQLAlchemy)
new_session = Session(
    session_id=session_id,
    user_id=user_id,
    created_at=datetime.datetime.now(),
    last_activity=datetime.datetime.now()
)
db.add(new_session)
db.commit()

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

<?php
setcookie('session_id', $session_id, [
    'expires'  => time() + 86400, // 24 ore
    'path'     => '/',
    'secure'   => true,       // Solo su HTTPS
    'httponly' => true,       // Non accessibile da JavaScript (protegge da XSS)
    'samesite' => 'Lax',      // Protegge da CSRF
]);
  • HttpOnly impedisce a JavaScript di leggere il cookie. Se un attaccante riesce a iniettare del codice JS nella pagina (attacco XSS), non può rubare la sessione.
  • Secure fa sì che il cookie viaggi solo su HTTPS. Senza questo flag, il cookie vola in chiaro sulle reti non cifrate.
  • SameSite=Lax limita l’invio del cookie alle richieste che partono dallo stesso sito o da navigazione top-level (clic su un link). Blocca le richieste cross-site automatiche, proteggendo da CSRF.

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 cancellare l’ID dal database, non solo dal browser. Altrimenti chiunque abbia intercettato o rubato il cookie può continuare a usarlo indisturbato.

<?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_id = ?");
    $stmt->execute([$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]);
}

I JSON Web Token sono ovunque e vengono spesso presentati come la soluzione moderna per tutto. La realtà è più sfumata.

Un JWT è un token firmato digitalmente 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.

Il problema dei JWT per le sessioni utente su browser è che sono più pesanti dei cookie — devono trasportare firma e intestazioni in ogni richiesta — e soprattutto 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 regola pratica è questa:

ScenarioSoluzione consigliata
Sessioni utente su browserCookie di sessione + database
Autenticazione tra API e serviziJWT
App mobile che chiama una tua APIJWT
Single Page Application con backend separatoJWT (con refresh token)

Per la maggior parte delle applicazioni web “classiche” — backend PHP o Python che rendono 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
<?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);
$expires_at  = date('Y-m-d H:i:s', time() + 1800); // 30 minuti

$stmt = $pdo->prepare("
    INSERT INTO password_resets (user_id, token_hash, expires_at)
    VALUES (?, ?, ?)
    ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), expires_at = VALUES(expires_at)
");
$stmt->execute([$user_id, $token_hash, $expires_at]);

$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:

App di autenticazione (TOTP): Google Authenticator, Authy e simili generano codici temporanei basati su un segreto condiviso via QR code. È il metodo più sicuro tra quelli largamente adottati.

SMS: È il metodo più diffuso e comprensibile per gli utenti. Non è il più sicuro in assoluto — esiste il SIM swapping — ma è infinitamente meglio di niente. La stragrande maggioranza degli attaccanti non ha le risorse per fare SIM forgery.

Hard token (YubiKey e simili): Dispositivi fisici USB o NFC. Sicurissimi, ma adottati solo in contesti aziendali o da utenti particolarmente attenti alla sicurezza.

# Python - TOTP con pyotp (pip install pyotp)
import pyotp

# Generazione del segreto (da salvare nel database per l'utente)
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"
if totp.verify(codice_utente):
    print("Codice valido, accesso consentito!")
else:
    print("Codice non valido.")

Un dettaglio che spesso si dimentica: 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 2025 funziona così:

  1. Registrazione: l’utente sceglie una password → la verifichi (lunghezza minima, non troppo comprimibile) → la hashi con Argon2id + salt → 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() → se valido, generi un session ID con CSPRNG (128+ bit), lo salvi nel database con scadenza, lo mandi come cookie HttpOnly + Secure + SameSite.

  3. Sessione: ad ogni richiesta, il browser manda il cookie → verifichi che l’ID 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 (hashato nel DB) → al click sul link, verifichi il token, cambi la password con nuovo hash Argon2, invalidi tutte le sessioni attive.

  6. 2FA: dopo il login con password corretta, chiedi il codice OTP prima di creare la sessione.

Non è rocketscience, 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.

content_copy Copiato