Skip to content
Torna al blog
Sicurezza

JWT invalid signature: tutte le cause e come risolverle

Firma JWT non valida (invalid signature): byte della chiave diversi tra linguaggi, a capo nel .env, algoritmo non corrispondente. Decoder online gratuito.

14 min di lettura

JWT invalid signature: tutte le cause e come risolverle

Un errore JWT invalid signature significa esattamente una cosa: la firma calcolata dal tuo verificatore non è uguale alla firma che il token si porta dietro. Il messaggio si esaurisce qui. Non dice niente sulla scadenza del token né sui permessi dell’utente, e non vuol dire che la tua libreria JWT sia rotta. Qualcosa nei byte che entrano nell’HMAC, o nella chiave pubblica che entra nella chiamata di verifica, è diverso tra il lato che ha firmato e il lato che controlla.

Nella maggior parte dei casi il colpevole è il materiale della chiave, non il token. Usa questo schema per scegliere da dove partire:

Quale algoritmo compare nell'header?
├─ HS256 / HS384 / HS512  → quasi sempre un problema di segreto
│    ├─ chi firma e chi verifica usano linguaggi diversi?  → Sezione 3
│    └─ stesso linguaggio, va in locale, fallisce in produzione? → Sezione 4
└─ RS256 / ES256 / PS256  → quasi sempre formato della chiave o chiave sbagliata
     └─ → Sezione 7

Il token è passato per un gateway, un proxy o un copia-incolla? → Sezione 6
L'errore compare solo dopo qualche ora o su un host solo?      → Sezione 8

Ogni sezione qui sotto finisce con qualcosa che puoi eseguire. Se ti serve subito la mossa più rapida, incolla il token nel decodificatore JWT e leggi il campo alg: metà dei rami qui sopra crolla nel momento in cui lo conosci.

1. Che cosa significa davvero “invalid signature”

Librerie diverse stampano stringhe diverse per lo stesso fallimento. Trova la tua in questo elenco, così sai di essere nella guida giusta:

  • Node jsonwebtoken: JsonWebTokenError: invalid signature
  • Python PyJWT: InvalidSignatureError: Signature verification failed
  • Java jjwt: SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

Tutte e tre scattano nello stesso momento, nello stesso punto del codice. La libreria prende i primi due segmenti del token, ricalcola la firma con la chiave che le hai passato e confronta il risultato byte per byte con il terzo segmento. Se non coincidono, solleva l’eccezione.

Il confronto è esatto e non porta con sé alcuna informazione su quanto i due valori siano diversi. Un segreto sbagliato di un solo byte e una chiave completamente sbagliata producono messaggi di errore identici. Per questo il resto della guida parla di restringere lo spazio degli input, invece di leggere l’errore con più attenzione.

Nota che cosa non è ancora successo quando scatta questo errore. La validazione dei claim gira dopo la verifica della firma, quindi exp, nbf, aud e iss non sono ancora stati guardati. Se la verifica della firma del tuo JWT è fallita, il contenuto del token è irrilevante per la diagnosi, anche se resta leggibile: un JWT è codificato, non cifrato. Per decodificare header e payload non serve nessuna chiave; se vuoi la spiegazione segmento per segmento, vedi come decodificare un token JWT.

Due campi dell’header decidono dove andare dopo: alg ti dice se stai inseguendo un segreto condiviso o una coppia di chiavi, e kid ti dice quale chiave chi ha firmato credeva di usare.

2. La firma copre la stringa codificata, non il tuo oggetto

Qui la maggior parte degli sviluppatori ha il modello mentale al contrario.

L’RFC 7515, la specifica JSON Web Signature, definisce il JWS Signing Input come la stringa ASCII:

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

L’HMAC si calcola su quella stringa. Non sulla tua mappa di claims, e non su nessun oggetto che il tuo linguaggio consideri un dato strutturato. Ecco il signing input usato in tutto questo articolo, ricavato dal payload di esempio standard:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

La conseguenza mette in trappola i team di continuo: qualsiasi livello che decodifica il payload e lo ricodifica distrugge la firma. La serializzazione JSON non è canonica. L’ordine delle chiavi cambia quando una mappa fa un giro di andata e ritorno nella maggior parte dei linguaggi. Gli spazi compaiono o spariscono. Un serializzatore fa l’escape dei caratteri non ASCII come \uXXXX, un altro li emette letteralmente. Il serializzatore riformatta anche i numeri, così 1516239022 può tornare indietro come 1516239022.0. Ognuna di queste cose produce una stringa base64url diversa, quindi un signing input diverso, quindi una firma diversa.

Casi reali che abbiamo visto:

  • Un API gateway che analizza il JWT per arricchirlo con un tenant ID e riemette il token.
  • Un middleware di logging o tracing che “normalizza” gli header e riscrive il valore di Authorization.
  • Uno sviluppatore che ha riformattato un token per leggerlo e poi ha rincollato la versione riformattata.

Se un componente qualsiasi tra chi firma e chi verifica può riscrivere il token, quel componente è il primo sospettato. In transito i token sono stringhe opache: le uniche operazioni sicure sono memorizzare, copiare e confrontare.

3. Stesso segreto, byte diversi

Questa è la causa che quasi nessun articolo di troubleshooting copre, e quella dietro a ogni segnalazione del tipo “il segreto è letteralmente identico, ho fatto il diff”.

L’HMAC non consuma una stringa. Consuma byte. Il tuo file di configurazione, il secrets manager e le variabili d’ambiente memorizzano tutti stringhe. Qualcosa deve convertire le une negli altri, e quella conversione non è standardizzata tra le librerie JWT. Due servizi possono avere segreti identici carattere per carattere e calcolare comunque firme diverse.

Ecco la prova, calcolata in locale sul signing input della Sezione 2. La stringa del segreto è lunga 36 caratteri:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Interpretazione dei byteByteChe cos’è davvero la chiaveFirma HS256 risultante
Trattata come testo UTF-836i 36 caratteri visibili stessitUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Decodificata prima da Base6427secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Stessa stringa di segreto, stesso algoritmo, stesso payload, e due firme che non hanno niente in comune. Il lato che l’ha presa “male” riporta invalid signature, e nessun diff del file di configurazione rivelerà mai niente, perché i file di configurazione coincidono.

Il token completo per la lettura UTF-8, se vuoi riprodurre la prova:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Incollalo nel decodificatore JWT insieme al segreto qui sopra e la verifica passa. Decodifica prima il segreto come base64 e non passa.

Come ogni libreria trasforma una stringa in byte di chiave

Attieniti a ciò che è documentato. La tabella qui sotto è volutamente ristretta, e l’ultima colonna conta più della prima.

Runtime / libreriaConversione da stringa a byteChi decide
Node jsonwebtokenbyte UTF-8 della stringala libreria
Python PyJWTbyte UTF-8 della stringala libreria
Java jjwt, overload String legacycodec base64 della piattaforma, secondo jwtk/jjwt#204la libreria
Go golang-jwtriceve direttamente []bytetu, nel punto di chiamata
.NETriceve direttamente byte[]tu, nel punto di chiamata

La riga di Java è la sorgente storica del dolore cross-stack, e va detta con precisione. Nelle vecchie versioni di jjwt, signWith(SignatureAlgorithm, String) e i suoi fratelli passavano la String attraverso un codec base64 invece di prenderne i byte grezzi, mentre gli overload con byte[] usavano i byte così com’erano. Un servizio Node e un servizio Java che condividevano lo stesso segreto finivano quindi per non essere d’accordo. Quell’API basata su String è deprecata da jjwt 0.10, e la forma moderna è esplicita:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Questo non è “come Java fa i JWT”. È l’overload legacy di una singola libreria, e il codice jjwt attuale che passa un byte[] non ha nessuna ambiguità. La segnalazione speculare sul lato Node è auth0/node-jsonwebtoken#208, dove i token firmati in Java non si verificavano in Node. Ci sono segnalazioni simili contro firebase/php-jwt in PHP (vedi firebase/php-jwt#153), anche se non abbiamo controllato di persona come quella libreria tratta i byte, quindi consideralo una pista e non una diagnosi.

Go e .NET stanno in un’altra categoria. Nessuna delle due librerie decide al posto tuo: entrambe ti consegnano il parametro []byte / byte[] e si fanno da parte. []byte(secret) e Encoding.UTF8.GetBytes(secret) danno UTF-8, mentre Convert.FromBase64String(secret) dà i byte decodificati. Il bug, quando c’è, vive nel tuo punto di chiamata, ed è una buona notizia: è visibile nel tuo diff.

Il mio segreto JWT è base64 o UTF-8?

Non c’è nessun flag nel token che te lo dica. Devi ragionare sulla stringa stessa:

  1. Usa solo A–Z a–z 0–9 + / = (oppure - e _)? Se sì, potrebbe essere base64. Un segreto che contiene uno spazio, un ! o un # non può esserlo.
  2. La sua lunghezza è un multiplo di 4, oppure finisce con il padding =? Entrambi sono indizi forti che qualcosa l’abbia codificata in base64 lungo il percorso.
  3. Decodificarla da base64 produce byte sensati? Passala nel decodificatore Base64. ASCII leggibile, o esattamente 32 byte dall’aria casuale, suggerisce base64. Caratteri illeggibili suggeriscono che la stringa non è mai stata codificata.

Un segreto come c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm supera tutti e tre i test, ed è proprio questo che lo rende pericoloso: è ambiguo, ed entrambe le letture sono plausibili. I segreti che contengono un - o un _ sono ambigui in modo ancora più insidioso, perché sono base64url validi ma base64 standard non validi.

Quando non riesci a ragionare fino a una risposta, calcola tutte e due. Prendi il signing input, eseguici sopra HMAC-SHA256 due volte nel generatore HMAC, una volta con il segreto come testo e una volta con i byte decodificati, poi confronta ciascun risultato con il terzo segmento del token. Uno dei due corrisponderà, e questo ti dice quale lato del tuo sistema ha ragione.

I caratteri non sono byte

La trappola imparentata è contare i caratteri quando il requisito è espresso in byte. L’RFC 7518 §3.2 fissa la soglia minima della chiave per HMAC-SHA in bit, non in caratteri, e il testo codificato si espande:

Come la scriviEntropiaByte equivalentiPer HS256 (servono ≥256 bit)
32 caratteri hex128 bit16 byte❌ sotto la soglia
32 caratteri base64192 bit24 byte❌ sotto la soglia
32 byte casuali256 bit32 byte✅ la raggiunge (64 caratteri in hex, 44 in base64 con padding)

Un “segreto da 32 caratteri” può valere da 128 a 256 bit a seconda dell’alfabeto. È un problema ortogonale rispetto a quello dell’interpretazione dei byte visto sopra, ma morde le stesse persone, perché un team che misura in caratteri di solito è un team che i byte non li ha mai guardati. Per le regole di scelta vere e proprie (lunghezza, codifica, rotazione) vai al generatore di segreti JWT: le sue note di riferimento le coprono per bene e non c’è motivo di duplicarle qui.

4. Il segreto si è contaminato per strada

I tuoi due servizi sono d’accordo sull’interpretazione dei byte. La firma continua a fallire. Adesso controlla se il segreto caricato da ciascun lato è il segreto che credi di aver scritto, perché l’impiantistica dell’ambiente è notevolmente brava ad aggiungere un byte.

A capo finale nel .env. Se JWT_SECRET=abc è seguito da un’interruzione di riga, alcuni lettori caricano abc\n. Un byte in più, e l’HMAC produce un output del tutto scorrelato: non resta nessuna somiglianza parziale da notare.

Virgolette lette come dato. JWT_SECRET="abc" significa abc per alcuni loader e "abc" per altri, soprattutto quando è una shell a caricare il file invece di una libreria che lo analizza. L’env_file di Docker Compose e un parser .env possono non essere d’accordo sullo stesso file.

Caratteri invisibili dal copia-incolla. Copiare un segreto da Slack, da un wiki o da un PDF può trascinarsi dietro uno spazio a larghezza zero (U+200B, byte e2 80 8b) o uno spazio non separabile (U+00A0, byte c2 a0). Nessun editor te li mostra, e tutti e due cambiano l’HMAC.

Manipolazioni di CI e container. Se un segreto passa per l’interpolazione della shell, il $ si espande e i backslash spariscono. Alcuni sistemi di CI tagliano gli spazi ai bordi dei valori, altri no. I secret di Kubernetes sono in base64 nel manifest e grezzi nel container: una trappola di doppia decodifica tutta sua.

La soluzione è smettere di guardare il segreto e cominciare a misurarlo. Su ciascun lato, stampa la lunghezza e un’impronta, mai il valore:

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

Esegui entrambi i comandi su chi firma e su chi verifica, poi confronta i due output. Lunghezza uguale e impronta uguale significano che il segreto non è il tuo problema: torna alla Sezione 3. Una lunghezza maggiore di uno rispetto a quella attesa è l’a capo finale. Una lunghezza maggiore di due sono le virgolette.

Quando la lunghezza non torna e vuoi vedere esattamente che cosa c’è dentro, fanne un dump esadecimale in una shell locale, su un segreto di sviluppo:

printf '%s' "$JWT_SECRET" | xxd

Uno 0a alla fine è un a capo. Un 22 iniziale e uno finale sono una coppia di virgolette. c2 a0 oppure e2 80 8b in mezzo è il caso dei caratteri invisibili. Non eseguirlo su un segreto di produzione da una macchina che spedisce l’output del terminale da qualche parte.

Il controllo equivalente dentro un processo Node o Python in esecuzione:

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

In Python, se len(s) conta meno di len(s.encode("utf-8")) allora ci sono caratteri non ASCII dentro un segreto che doveva essere ASCII.

5. Algoritmo e tipo di chiave non corrispondono

L’header alg e la chiave che passi devono appartenere alla stessa famiglia. HS256 vuole un segreto condiviso, cioè una stringa di byte. RS256 ed ES256 vogliono una chiave asimmetrica, cioè un PEM o un JWK. Incrocia i fili e ottieni un fallimento che, a seconda di quanto è indulgente la libreria, può essere un chiaro errore di tipo oppure un banale invalid signature.

Le varianti più comuni:

  • L’header dice HS256 e chi verifica passa alla libreria una chiave pubblica PEM. Alcune librerie calcolano l’HMAC sul testo del PEM e segnalano una firma che non corrisponde.
  • L’header dice RS256 e chi verifica passa la stringa del segreto HMAC.
  • Chi verifica non passa nessuna lista di algoritmi e lascia che la libreria la deduca da alg, così una deriva di configurazione sul lato che firma cambia in silenzio ciò che fa il verificatore.

Quest’ultima è il punto in cui un bug di configurazione diventa un bug di sicurezza, quindi fissa esplicitamente l’algoritmo a ogni chiamata di verifica:

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

Fissarlo converte anche gli errori di firma vaghi in errori precisi. Se arriva un token con alg: RS256 e la tua allowlist dice HS256, ottieni un errore esplicito sull’algoritmo che nomina entrambi i valori.

Finora abbiamo parlato di una configurazione sbagliata: due tuoi componenti che non sono d’accordo, senza nessun avversario di mezzo. Esiste un fallimento imparentato con la stessa forma, in cui un attaccante riscrive alg da RS256 a HS256 e firma usando la tua chiave pubblica come segreto HMAC. Quella è la confusione di algoritmi, è un attacco e non un bug, ed è trattata nelle best practice di sicurezza JWT insieme al resto del modello di minaccia. La difesa (un’allowlist esplicita) guarda caso è la stessa, il che è un buon argomento per applicarla anche quando stai solo inseguendo un bug.

6. Il token è cambiato in transito

Prima di dare la colpa alle chiavi, conferma che chi verifica abbia ricevuto la stessa stringa prodotta da chi firma. Un JWT è fragile esattamente nei modi in cui sono fragili le stringhe.

Il prefisso Bearer. Authorization: Bearer eyJhbGci... è il valore di un header, non un token. Se fai lo split sulla cosa sbagliata, o lo fai una volta sola tenendo la metà sbagliata, ti ritrovi a verificare Bearer eyJhbGci... oppure una stringa vuota. Toglilo di proposito:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

Spazi e interruzioni di riga. I token copiati da un terminale vanno a capo, e YAML ripiega quelli lunghi su più righe. Un singolo \n incorporato nel terzo segmento produce una firma che non corrisponde, non un errore di parsing, perché i decodificatori base64url spesso saltano gli spazi mentre il confronto tra stringhe no.

Codifica URL. Un token che ha viaggiato come parametro di query può tornare indietro con . diventato %2E, oppure con - e _ tradotti da un encoder troppo zelante. Decodifica una volta, esattamente una volta.

Troncamento. I cookie si fermano intorno ai 4 KB ciascuno, e i token RS256 con qualche claim li superano regolarmente. Un token troncato di solito fallisce la decodifica base64, ma se il taglio cade su un confine di 4 caratteri ottieni invece un token dall’aria valida con una firma sbagliata.

Due comandi chiudono la questione. Un JWT ben formato ha esattamente due punti:

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

E ogni carattere deve appartenere all’alfabeto base64url, quindi questo non dovrebbe stampare proprio niente:

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

Qualsiasi output dal secondo comando nomina il tuo problema: 3d è il padding = che non dovrebbe esserci, 2b o 2f sono il + e il / del base64 standard là dove base64url si aspetta - e _, e 20 è uno spazio vagante.

7. Fallimenti specifici di RS256 ed ES256

Gli algoritmi asimmetrici scambiano il problema del segreto con un problema di gestione delle chiavi. Le modalità di fallimento sono abbastanza diverse da meritarsi un elenco a parte.

PKCS#1 contro PKCS#8. Sono due formati contenitore per la stessa chiave RSA, e si distinguono a vista per una sola parola nella riga di intestazione:

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

Le librerie variano su quale dei due accettano. Quando una rifiuta il formato di netto ottieni un errore chiaro; quando lo analizza a metà puoi ottenere una firma che non verifica mai. Converti invece di combattere:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

Le chiavi sono invertite. Firmi con la chiave pubblica, o verifichi con quella privata. Ovvio in teoria, facilissimo da fare quando i due file stanno nella stessa directory con nomi che differiscono per quattro caratteri. Controlla quale dei due è la privata:

openssl rsa -in key.pem -noout -text | head -1

Una chiave privata stampa la dimensione del suo modulo come chiave privata; una chiave pubblica va in errore, a meno che tu non aggiunga -pubin.

JWKS e deriva del kid. Con un endpoint JWKS, chi verifica sceglie una chiave confrontando il kid del token con il key set. Qui gli sbagli ricorrenti sono sempre gli stessi: chi firma ha ruotato le chiavi e il JWKS in cache del verificatore è vecchio; il token non ha kid e il verificatore prende la prima chiave del set; oppure due ambienti pubblicano valori di kid che si sovrappongono. Quando sospetti questo caso, scarica di nuovo il JWKS e conferma che il kid esatto letto dall’header del token sia presente.

Codifica della firma ES256. Le firme ECDSA sono una coppia di interi, r e s, e ci sono due modi di serializzarli. Gli stack crittografici generici spesso emettono DER, una struttura ASN.1 a lunghezza variabile. L’RFC 7518 §3.4 richiede invece la forma JOSE: r e s riempiti ciascuno fino a una lunghezza fissa e poi concatenati, che per P-256 fanno 64 byte. Una firma DER infilata dentro un JWT si riconosce già dalla lunghezza, prima ancora che dal contenuto. Quindi un token ES256 il cui terzo segmento non si decodifica in esattamente 64 byte è stato costruito da qualcosa che ha saltato la conversione.

Per isolare se il problema è la tua chiave o la tua pipeline, firma lo stesso payload in modo indipendente nel codificatore JWT e confronta l’output con quello prodotto dal tuo servizio. Firme identiche puntano al trasporto o alla gestione dei claim. Firme diverse puntano alla chiave.

8. Errori che sembrano fallimenti di firma ma non lo sono

Certe etichette le sbagliano le librerie stesse, ed è così che finiscono nella segnalazione di bug sbagliata.

SintomoChe cos’è in realtàDove guardare
PyJWT ExpiredSignatureErrorexp è nel passato. Il nome dice signature; la causa è un claim.Sfasamento degli orologi tra gli host, o un TTL troppo corto
PyJWT ImmatureSignatureErrornbf è nel futuroL’orologio di chi firma è avanti rispetto a quello di chi verifica
Node TokenExpiredErrorexp è nel passatoCome sopra
401 generico, senza dettagliIl framework ha compresso ogni fallimento di verifica in un’unica rispostaAttiva il logging degli errori a livello di libreria
Funziona per qualche minuto, poi fallisceScadenza del token, non firmaConfronta iat ed exp con gli orologi di entrambi gli host
Fallisce solo per una audienceaud o iss non corrispondonoLa lista di audience attese da chi verifica

La nomenclatura di PyJWT è la trappola più vistosa. ExpiredSignatureError contiene la parola “signature” ma viene sollevata durante la validazione dei claim, molto dopo che la firma è già stata verificata con successo. Cercare la stringa di errore porta dritti a materiale sul troubleshooting delle firme, e le ore spariscono nella sezione sbagliata del problema.

Lo sfasamento degli orologi produce lo schema più confuso di tutti: fallimenti intermittenti che non sono correlati a niente nel tuo codice. Se l’orologio di un host va avanti, i token appena emessi falliscono la validazione di nbf o iat all’arrivo, e i fallimenti si spostano man mano che la deriva cresce. Come prima cosa confronta date -u sulle due macchine. La maggior parte delle librerie accetta un parametro di tolleranza (leeway), che è la soluzione giusta per uno sfasamento che non puoi eliminare e quella sbagliata per un orologio davvero rotto.

La regola generale: se il fallimento dipende dal tempo, dall’host o dall’audience, non è un problema di firma. I fallimenti di firma sono deterministici: lo stesso token con la stessa chiave fallisce sempre allo stesso modo.

9. Un flusso di troubleshooting ripetibile

Esegui i passi in ordine. Ognuno o trova il bug o elimina un ramo, e fermarsi presto è proprio il punto.

  1. Decodifica l’header. Incolla il token nel decodificatore JWT e annota alg e kid. Questo decide tutto quello che viene dopo e non richiede nessuna chiave.
  2. Controlla la forma del token. Esattamente due punti, solo caratteri base64url, nessun prefisso Bearer, nessuno spazio. Usa i due comandi della Sezione 6. Elimina la corruzione in transito.
  3. Fissa l’algoritmo nella chiamata di verifica. Se c’è una discrepanza tra alg e la tua allowlist, adesso ottieni un errore esplicito che nomina entrambi invece di uno generico.
  4. Calcola l’impronta della chiave sui due lati. Stampa la lunghezza in byte e uno SHA-256 troncato su chi firma e su chi verifica, come nella Sezione 4. Valori diversi significano che la colpa è dell’impiantistica, e al passo 5 non ci arrivi nemmeno.
  5. Se i due lati sono in linguaggi diversi, risolvi l’interpretazione dei byte. Consulta la tabella della Sezione 3, decidi esplicitamente se il segreto è testo o base64, e fa’ in modo che entrambi i lati lo dicano nel codice invece di lasciarlo alle impostazioni predefinite.
  6. Rifirma lo stesso payload in modo indipendente. Usa il codificatore JWT con la chiave che credi corretta e confronta il suo terzo segmento con quello del tuo token. Se coincidono, il lato che firma sta bene ed è chi verifica il problema.
  7. Ricontrolla l’HMAC a mano. Passa il signing input nel generatore HMAC con entrambe le interpretazioni dei byte. Quella che corrisponde al token ti dice quale lato cambiare.

Se arrivi in fondo a tutti e sette e ti serve ancora aiuto, tieni presente che la maggior parte delle segnalazioni si arena perché omette proprio i fatti che determinano la risposta. Includi questi:

  • Il valore di alg preso dall’header, e se è presente un kid
  • Linguaggio, libreria e versione esatta su entrambi i lati, quello che firma e quello che verifica
  • La lunghezza in byte del segreto sui due lati, e i primi 16 caratteri esadecimali del suo SHA-256 (mai il segreto stesso)
  • Se il segreto è memorizzato come testo o come base64, e come lo converte ciascun lato
  • Il signing input completo. I primi due segmenti non sono sensibili: chiunque abbia il token può leggerli comunque
  • Per RS256 ed ES256: la riga di intestazione del PEM, testuale

Con quella lista, un “la firma del mio JWT non corrisponde” a cui nessuno sa rispondere diventa una domanda risolvibile, di solito in una sola risposta.

FAQ

Perché lo stesso segreto funziona in un linguaggio e fallisce in un altro?

Perché le librerie non sono d’accordo su come trasformare la stringa di un segreto in byte di chiave. Node jsonwebtoken e Python PyJWT usano UTF-8; l’overload String legacy di jjwt usava un codec base64 (jwtk/jjwt#204); Go e .NET lasciano la decisione al tuo punto di chiamata. Stessi caratteri, byte diversi, HMAC diverso.

La firma copre il payload decodificato o la stringa codificata?

La stringa codificata. L’RFC 7515 definisce il signing input come base64url(header) + "." + base64url(payload) in ASCII letterale. Qualsiasi livello che deserializza il payload e lo riserializza cambia l’ordine delle chiavi, gli spazi o la formattazione dei numeri: la stringa che ne esce è diversa, e con essa la firma.

Il mio segreto sembra base64: devo decodificarlo prima di firmare?

Solo se lo fa anche l’altro lato. Non esiste una risposta corretta in assoluto; il requisito è che le due estremità siano d’accordo. Controlla se la stringa usa solo caratteri base64 e ha una lunghezza multipla di quattro, poi rendi la scelta esplicita nel codice su entrambi i lati invece di affidarti alle impostazioni predefinite.

Un a capo finale nel .env può davvero rompere la firma?

Sì. L’HMAC consuma byte, e abc\n sono quattro byte dove abc ne sono tre. La firma che ne esce non ha niente in comune con quella corretta. Stampa printf '%s' "$JWT_SECRET" | wc -c sui due host; una lunghezza maggiore di uno rispetto all’attesa è quasi sempre questo.

Come capisco se è il segreto o l’algoritmo?

Leggi prima alg dall’header. Se inizia per HS, ti serve un segreto condiviso e un PEM fallirà. Se inizia per RS, PS o ES, ti serve una coppia di chiavi e la stringa di un segreto fallirà. Una volta che alg e tipo di chiave appartengono alla stessa famiglia, i fallimenti che restano sono problemi di contenuto della chiave.

Perché jwt.io dice che la firma è valida ma il mio server la rifiuta?

Perché lo strumento online e il tuo server possono interpretare il segreto in modo diverso: uno come testo UTF-8, l’altro come base64. Lo strumento verifica rispetto ai byte che ha ricavato lui, non a quelli ricavati dal tuo server. E poi: non incollare mai segreti di produzione in un sito di terze parti, usa una chiave di sviluppo.

Un token scaduto può causare “invalid signature”?

No. La verifica della firma gira prima della validazione dei claim, quindi la scadenza non ne è mai la causa. La scadenza emerge separatamente come TokenExpiredError in Node o ExpiredSignatureError in PyJWT. Il nome di quest’ultimo è fuorviante: la firma è stata verificata benissimo, è fallito solo exp.

Conclusione

Le firme che non corrispondono non sono quasi mai un problema di crittografia. HMAC-SHA256 funziona. RSA funziona. Quello che fallisce è il confine dove una stringa diventa byte: un codec base64 da una parte e UTF-8 dall’altra, un a capo che un loader di configurazione si è tenuto, un payload che un gateway ha premurosamente riserializzato. Ogni causa di questa guida è un disaccordo sui byte.

Quindi rendi i byte espliciti e smetti di affidarti alle impostazioni predefinite. Scrivi nella documentazione del tuo team se il segreto condiviso è memorizzato come testo grezzo o come base64, e assicurati che ogni servizio lo converta nel modo dichiarato invece di ereditare quello che la sua libreria ha dato per scontato. Per i sistemi che attraversano più linguaggi, memorizza i segreti in hex o base64 e decodificali esplicitamente in ogni punto di chiamata: una riga per servizio, e l’ambiguità sparisce. Poi aggiungi al tuo health check l’impronta della lunghezza in byte della Sezione 4, così il prossimo disallineamento si presenta come un warning all’avvio invece che come un 401 in produzione.

Tag: jwt authentication debugging hmac api-security

Articoli correlati

Vedi tutti gli articoli