Sicurezza JWT in PHP: vulnerabilità nell'implementazione e come costruire token sicuri
Il 28 ottobre 2025 mi ha contattato il CTO di una piattaforma fintech bergamasca che gestisce una soluzione di Open Banking per consulenti finanziari indipendenti italiani - 640 utenti paganti fra consulenti e studi commercialisti, circa 12.000 clienti finali dei consulenti con dati finanziari integrati, fatturato SaaS mensile ricorrente di 68.000 euro. Il CTO mi aveva ingaggiato per un penetration test dell'API centrale dell'applicazione, che usa JSON Web Token per l'autenticazione di tutte le chiamate da parte dei client mobile e desktop. L'azienda aveva ricevuto richiesta formale di certificazione ISO 27001 da parte di un cliente enterprise potenziale (un fondo di investimento da 180 milioni di AUM) e stava preparando la postura di sicurezza prima dell'audit esterno. Nel briefing iniziale il CTO mi ha detto con orgoglio che il loro sistema JWT era "fatto a regola d'arte" usando la libreria firebase/php-jwt - uno dei pacchetti più popolari dell'ecosistema Composer, scaricato oltre 250 milioni di volte secondo Packagist.
Nelle prime quattro ore dell'assessment ho trovato in produzione tre vulnerabilità critiche correlate all'implementazione JWT, tutte sfruttabili in pochi minuti da un attaccante con accesso anche limitato all'applicazione. La prima vulnerabilità era un algorithm confusion attack - il codice applicativo verificava i token senza specificare esplicitamente l'algoritmo di firma atteso, permettendo a un attaccante di forgiare token validi usando alg: none (firma vuota). La seconda vulnerabilità era una weak secret - il secret HMAC usato per firmare i token aveva 14 caratteri derivati da una parola italiana + anno di fondazione dell'azienda, bruteforcable in meno di 8 ore con hashcat su GPU singola. La terza vulnerabilità era l'assenza totale di meccanismo di revocation - una volta emesso un token con scadenza 30 giorni, anche se un dipendente veniva licenziato il token rimaneva valido fino alla scadenza naturale senza modo di invalidarlo centralmente. Il CTO è rimasto in silenzio per diversi minuti durante il debrief iniziale. In cinque giornate di lavoro successivo - fra assessment completo, dimostrazione delle vulnerabilità al team interno con proof-of-concept, e implementazione guidata della remediation - abbiamo riscritto l'intera gestione JWT dell'applicazione applicando i pattern corretti, rigenerato tutti i token esistenti con un nuovo secret crittograficamente sicuro, implementato un meccanismo di revocation centralizzato basato su Redis con denylist dei token compromessi.
Questo articolo è il distillato delle vulnerabilità JWT più frequenti che trovo in applicazioni PHP di produzione negli ultimi cinque anni, con il pattern di implementazione corretto per ciascuna. Il principio guida è uno: JWT è uno strumento crittografico sofisticato con molte trappole sottili, e la maggioranza delle librerie PHP popolari ha default permissive che facilitano la vulnerabilità se usati senza consapevolezza. La differenza fra un'implementazione JWT sicura e una vulnerabile non è la libreria scelta - è la disciplina con cui ogni parametro viene esplicitamente configurato.
L'algorithm confusion attack: la vulnerabilità JWT più diffusa nelle PMI italiane
Il vettore di attacco storicamente più devastante contro implementazioni JWT è il cosiddetto algorithm confusion attack, descritto formalmente come CVE-2015-9235 e documentato in dettaglio nella cheatsheet ufficiale OWASP sulla sicurezza dei JSON Web Token. Il problema deriva dalla flessibilità stessa dello standard JWT: un token dichiara nel proprio header quale algoritmo di firma è stato usato per firmarlo, e la libreria di verifica lato server deve leggere questo header per sapere come validare la firma. Un'implementazione ingenua, o una libreria con default permissive, si fida del valore dell'algoritmo dichiarato nel token ricevuto, senza imporre vincoli. Questo apre due scenari di attacco classici.
Primo scenario: attacco alg: none. L'attaccante modifica un token valido impostando l'algoritmo nell'header a none (che nello standard originale JWT significa "token non firmato") e rimuovendo completamente la firma. Un'implementazione che accetta alg: none considera il token valido senza verificare alcuna firma - l'attaccante può inserire claim arbitrarie (es. "role": "admin", "sub": "1") e ottenere privilegi elevati. Lo scenario è stato mitigato in molte librerie moderne che rifiutano none di default, ma su PHP con vecchie versioni di firebase/php-jwt (sotto la 6.0) e con codice applicativo che non specifica esplicitamente l'algoritmo atteso, la vulnerabilità è ancora presente. Sul cliente bergamasco, il codice del middleware di autenticazione chiamava JWT::decode($token, $key) senza il parametro $allowed_algs - vulnerabilità da manuale.
Secondo scenario, più sottile: attacco RSA-to-HMAC. L'applicazione usa RSA (chiave pubblica + privata asimmetrica) per firmare i token, e la chiave pubblica RSA è pubblicamente accessibile (come dovrebbe essere, è pubblica). L'attaccante firma un token con HMAC usando la chiave pubblica RSA come segreto HMAC, e modifica l'header a alg: HS256. Se la libreria di verifica non controlla che l'algoritmo atteso sia RSA, tenta di verificare con HMAC usando la "chiave" - che è la chiave pubblica RSA nota pubblicamente - e il check passa. L'attaccante ha firmato un token valido senza conoscere la chiave privata. Il fix in firebase/php-jwt moderno è l'uso della classe Key con algoritmo esplicitamente dichiarato:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$decoded = JWT::decode(
$token,
new Key($publicKey, 'RS256')
);La versione vulnerabile era JWT::decode($token, $publicKey) senza il wrapping in Key e senza specifica dell'algoritmo. Il pattern di auditing che applico nelle applicazioni legacy è cercare con grep tutte le chiamate JWT::decode( e verificare che il secondo parametro sia sempre un'istanza di Key con algoritmo esplicito, mai una stringa grezza.
Se stai gestendo un'API con autenticazione JWT su PHP/Laravel/Symfony e non hai mai fatto un assessment mirato sulla implementazione, nel mio profilo professionale trovi il dettaglio degli assessment JWT e di autenticazione API che ho condotto su fintech italiane e altre PMI con esposizione di sicurezza elevata, sempre con approccio di remediation pratica e documentazione formale.
Weak secret: il brute-force dei JWT HMAC è più facile di quanto pensi
La seconda vulnerabilità JWT più diffusa è l'uso di un secret HMAC debole per firmare i token. Il livello minimo di robustezza di un secret HMAC per JWT dovrebbe essere 256 bit di entropia reale - equivalenti a 32 byte random generati crittograficamente, o 43 caratteri base64. Nella pratica delle PMI italiane, i secret che trovo nei .env di produzione sono quasi sempre molto più deboli: frasi italiane con qualche modifica minore, date aziendali rilevanti, sigla dell'azienda + anno, o worse - il secret di default della documentazione del pacchetto che nessuno ha cambiato al momento del deploy iniziale.
Lo strumento di attacco canonico contro JWT HMAC deboli è hashcat con modalità 16500 (JWT HS256) o simili per HS384/HS512. Un secret di 14 caratteri derivato da parole di dizionario italiano più suffisso numerico è bruteforcable in 4-12 ore su una singola GPU consumer moderna (RTX 4090 o simile) a circa 250-400 MH/s. Un secret di 20 caratteri completamente random è bruteforcable in qualche anno su hardware consumer, e in qualche mese su hardware server dedicato al cracking. Un secret di 32+ caratteri crittograficamente random è praticamente inviolabile con tecnologie attuali - questa è la soglia di sicurezza che applico sempre.
Il pattern di generazione sicura è banalmente:
php -r "echo bin2hex(random_bytes(32));"
# oppure
openssl rand -hex 32
# oppure
head -c 32 /dev/urandom | base64Tutti questi producono un secret di 64 caratteri hex o 44 caratteri base64 con 256 bit di entropia. Il secret va inserito nel .env come variabile (JWT_SECRET=...), caricato dall'applicazione via configurazione, mai committato nel repository come stringa hardcoded, mai usato lo stesso fra ambienti diversi (staging e produzione devono avere secret diversi), e ruotato periodicamente come descrivo nel mio articolo sulla gestione sicura dei file .env in produzione per Laravel e Symfony. Per applicazioni che accettano token da lungo tempo in circolazione, la rotazione del secret richiede una strategia di transizione - supporto di più secret validi contemporaneamente per un periodo - che è un pattern più sofisticato e va progettato esplicitamente prima della prima rotazione.
Validazione dei claim: expiration, issuer, audience, not-before
La terza categoria di vulnerabilità JWT riguarda la validazione incompleta dei claim del token. Un JWT standard ha alcuni claim registrati dallo RFC 7519 che hanno significato semantico importante per la sicurezza: exp (expiration), iat (issued at), nbf (not before), iss (issuer), aud (audience), sub (subject), jti (JWT ID). Un'implementazione completa deve validare tutti i claim rilevanti al momento della verifica del token, altrimenti apre vettori di attacco.
Il claim exp è il più critico: se il server non verifica la scadenza, un token emesso due anni fa è ancora accettato oggi. Questo elimina completamente il concetto di sessione a tempo limitato. La libreria firebase/php-jwt verifica exp automaticamente dalla versione 6.x, ma alcune librerie community meno mantenute potrebbero non farlo - da verificare sempre nel codice o nei test.
Il claim iss serve a validare che il token è stato emesso dall'autorità attesa. In applicazioni multi-tenant o con più emittenti legittimi, controllare che iss corrisponda al valore atteso per il contesto della richiesta previene riuso di token validi emessi da istanze diverse. Il claim aud serve a validare che il token è destinato al servizio corrente. In architetture di microservizi dove token emessi dallo stesso provider vengono presentati a servizi diversi, il controllo di aud previene l'uso di token emessi per il servizio A presso il servizio B. Il claim nbf è utile per token con attivazione ritardata (un token emesso oggi ma valido solo da domani); applicazioni tipiche PMI raramente lo usano, ma se presente va verificato.
Il pattern di validazione completa che applico è esplicitare ogni claim atteso nel codice di verifica, mai fidarsi dei default:
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
if ($decoded->iss !== config('jwt.expected_issuer')) {
throw new TokenInvalidException('Invalid issuer');
}
if ($decoded->aud !== config('jwt.expected_audience')) {
throw new TokenInvalidException('Invalid audience');
}
if (!in_array($decoded->sub, $this->allowedSubjects($context))) {
throw new TokenInvalidException('Subject not authorized in this context');
}L'ultimo controllo nell'esempio - la verifica che sub (l'utente) sia autorizzato nel contesto corrente - è spesso dimenticato ma cruciale: un token valido emesso per un utente non significa automaticamente che quell'utente abbia il diritto di eseguire l'operazione richiesta. L'autenticazione (chi sei) è diversa dall'autorizzazione (cosa puoi fare), e il JWT risolve solo la prima - la seconda è responsabilità dell'applicazione.
Revocation: il problema strutturale di JWT e come risolverlo con denylist Redis
Il difetto di progettazione più significativo dello standard JWT - discusso ampiamente nella community crittografica e spesso ignorato dalle implementazioni PMI - è l'assenza nativa di meccanismo di revocation. Una volta emesso, un JWT resta valido fino alla sua scadenza naturale; non esiste modo nello standard di invalidarlo prima. Questo è in contrasto con i classici session cookie lato server, dove il server può invalidare una sessione in qualunque momento semplicemente eliminandola dallo storage. Nelle applicazioni reali, questa limitazione è problematica in molti scenari: un utente che cambia password (i token emessi prima dovrebbero essere invalidati), un dipendente che viene licenziato (i suoi token attivi vanno revocati immediatamente), un device che viene rubato (la sessione associata va terminata).
La soluzione architetturale standard per PMI che applico è JWT short-lived + refresh token + denylist Redis. I token JWT di accesso hanno scadenza molto breve (tipicamente 15 minuti); per uso continuativo il client usa un refresh token a scadenza più lunga (7-30 giorni) per ottenere un nuovo access token. Un servizio di denylist centralizzato su Redis mantiene la lista di token ID (jti claim) revocati esplicitamente. La verifica JWT include un check contro la denylist: se il jti del token è presente in denylist, il token è rifiutato anche se crittograficamente valido. La denylist ha TTL uguale alla scadenza massima dei token (15 minuti per access, 30 giorni per refresh), quindi non cresce indefinitamente.
Il pattern operativo implementato sul cliente bergamasco è:
namespace App\Infrastructure\Auth;
final class RedisTokenDenylist implements TokenDenylist
{
public function __construct(
private readonly Redis $redis,
private readonly Clock $clock
) {}
public function revoke(string $jti, int $expiresAt): void
{
$ttl = max(0, $expiresAt - $this->clock->now()->getTimestamp());
$this->redis->setex("revoked:jwt:{$jti}", $ttl, '1');
}
public function isRevoked(string $jti): bool
{
return (bool) $this->redis->exists("revoked:jwt:{$jti}");
}
}Al momento del logout, dell'cambio password, o di qualunque evento che richieda revocation immediata, l'applicazione chiama $denylist->revoke($token->jti, $token->exp) e il token diventa immediatamente invalido su tutte le istanze applicative che condividono lo stesso Redis. Il pattern si integra perfettamente con i principi di gestione sessioni sicure in PHP che ho descritto in un articolo dedicato, fornendo il meccanismo di invalidazione centralizzata che altrimenti mancherebbe in un'architettura JWT pura.
Altri pattern di sicurezza: rate limiting, binding al client, claim personalizzate
Oltre alle tre vulnerabilità principali discusse finora, ci sono altri pattern di hardening JWT che applico in applicazioni con requisiti di sicurezza più stringenti. Rate limiting dedicato per endpoint di emissione token - bloccare tentativi di brute-force di credenziali tramite /login o /refresh via rate limit per IP e per account. Client binding - includere nel payload del token un fingerprint del client (user agent, partial IP, custom device identifier) e verificare al server che il token non venga riutilizzato da un contesto client diverso. Claim personalizzate per dati sensibili - se il token contiene informazioni come il ruolo utente o il tenant ID, assicurarsi che queste siano considerate "public" per il client (JWT payload è base64 decodificabile chiunque lo veda) e non contenga dati privati come email, PII, token di altri sistemi.
Il risultato finale dell'intervento sul cliente bergamasco, al termine delle cinque giornate di remediation più due settimane di monitoraggio, è stato il seguente. Tutte e tre le vulnerabilità critiche identificate risolte con proof esplicito (riapplicazione dei PoC di attacco con verifica di rigetto da parte del sistema corretto). Secret JWT rigenerato con entropy adeguata e rotato su tutti gli ambienti. Meccanismo di revocation attivo e testato con scenari realistici (logout, cambio password, licenziamento dipendente). Validazione completa dei claim standard (exp, iss, aud, sub) implementata consistentemente in tutti gli endpoint. Audit log centralizzato di eventi di autenticazione (emissione token, revocation, tentativi di autenticazione falliti) predisposto per revisione da parte del team di sicurezza o auditor esterni. Il CTO ha potuto presentare al cliente enterprise potenziale (il fondo di investimento da 180 milioni di AUM) un report di security assessment con le vulnerabilità identificate e risolte, e il contratto commerciale è stato firmato due mesi dopo per un valore triennale di circa 280.000 euro.
Se gestisci un'applicazione PHP con API autenticata via JWT e non hai mai fatto un assessment tecnico mirato specificamente sull'implementazione JWT, la probabilità statistica che il tuo sistema abbia almeno una delle vulnerabilità descritte in questo articolo è superiore all'80% secondo la mia esperienza su circa 40 assessment degli ultimi cinque anni. L'investimento in un assessment mirato di 2-4 giornate produce un ROI difensivo misurabile e spesso è la prerequisite per certificazioni ISO 27001 o contratti enterprise con clienti che richiedono formal security posture. Se vuoi confrontarti sul tuo caso specifico con un assessment JWT mirato della tua API, contattami per una consulenza iniziale: in due-tre giornate di penetration test focalizzato produco un report dettagliato con i vettori di attacco specifici, i proof-of-concept dimostrativi, e una roadmap di remediation prioritizzata sull'impatto reale delle esposizioni rilevate.