JWT „invalid signature“: alle Ursachen und wie Sie sie beheben
Der Fehler invalid signature bei einem JWT bedeutet genau eines: Die Signatur, die Ihr Prüfer berechnet hat, ist nicht identisch mit der Signatur, die das Token mitbringt. Mehr steckt nicht in dieser Meldung. Sie besagt nicht, dass das Token abgelaufen ist, dass dem Benutzer eine Berechtigung fehlt oder dass Ihre JWT-Bibliothek kaputt ist. Irgendetwas an den Bytes, die in den HMAC einfließen, oder am öffentlichen Schlüssel, der in den Verify-Aufruf geht, unterscheidet sich zwischen der Seite, die signiert hat, und der Seite, die prüft.
In den meisten Fällen liegt die Ursache im Schlüsselmaterial, nicht im Token. Damit finden Sie Ihren Ausgangspunkt:
Welcher Algorithmus steht im Header?
├─ HS256 / HS384 / HS512 → fast immer ein Problem mit dem Secret
│ ├─ Signierer und Prüfer in verschiedenen Sprachen? → Abschnitt 3
│ └─ gleiche Sprache, lokal grün, produktiv rot? → Abschnitt 4
└─ RS256 / ES256 / PS256 → fast immer das Schlüsselformat oder der falsche Schlüssel
└─ → Abschnitt 7
Token lief durch Gateway, Proxy oder Copy-and-paste? → Abschnitt 6
Fehler erst nach einigen Stunden oder nur auf einem Host? → Abschnitt 8
Jeder Abschnitt endet mit etwas, das Sie ausführen können. Wenn Sie den schnellstmöglichen ersten Schritt wollen: Fügen Sie das Token in den JWT-Dekodierer ein und lesen Sie das Feld alg. Die Hälfte der Zweige oben löst sich auf, sobald Sie ihn kennen.
1. Was „invalid signature“ tatsächlich bedeutet
Verschiedene Bibliotheken geben für denselben Fehlschlag verschiedene Meldungen aus. Suchen Sie Ihre in dieser Liste, dann wissen Sie, dass Sie im richtigen Leitfaden gelandet sind:
- 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.
Alle drei feuern im selben Moment auf demselben Codepfad. Die Bibliothek nimmt die ersten beiden Segmente Ihres Tokens, berechnet die Signatur mit dem Schlüssel neu, den Sie ihr übergeben haben, und vergleicht das Ergebnis Byte für Byte mit dem dritten Segment. Nicht gleich, Exception.
Der Vergleich ist exakt und transportiert keinerlei Information darüber, wie stark sich die beiden Werte unterscheiden. Ein Secret, das um ein einziges Byte abweicht, und ein komplett falscher Schlüssel erzeugen dieselbe Fehlermeldung. Der Rest dieses Leitfadens grenzt deshalb den Raum der Eingaben ein, statt die Fehlermeldung noch genauer zu lesen.
Achten Sie darauf, was noch nicht passiert ist, wenn dieser Fehler auftritt. Die Claim-Prüfung läuft erst nach der Signaturprüfung, also hat noch niemand auf exp, nbf, aud oder iss geschaut. Wenn die Signaturprüfung Ihres JWT fehlschlägt, ist der Inhalt des Tokens für die Diagnose irrelevant. Lesbar bleibt er trotzdem, denn ein JWT ist kodiert und nicht verschlüsselt. Header und Payload zu dekodieren braucht überhaupt keinen Schlüssel; wenn Sie den Durchgang Segment für Segment sehen wollen, siehe JWT dekodieren.
Zwei Felder im Header entscheiden, wohin Sie als Nächstes gehen: alg sagt Ihnen, ob Sie ein gemeinsames Secret oder ein Schlüsselpaar jagen, und kid sagt Ihnen, welchen Schlüssel der Signierer zu verwenden glaubte.
2. Die Signatur deckt die kodierte Zeichenkette ab, nicht Ihr Objekt
Die meisten Entwickler haben dieses Modell falsch herum im Kopf, und alles Weitere baut darauf auf.
RFC 7515, die Spezifikation zu JSON Web Signature, definiert den JWS Signing Input als die ASCII-Zeichenkette:
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
Der HMAC läuft über diese Zeichenkette. Nicht über Ihre claims-Map, nicht über ein JSON-Objekt oder sonst etwas, das Ihre Sprache für strukturierte Daten hält. Diese Signatureingabe verwendet der Artikel durchgehend, entnommen aus dem Standard-Beispiel-Payload:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
Die Folge erwischt Teams permanent: Jede Schicht, die den Payload dekodiert und neu kodiert, zerstört die Signatur. JSON-Serialisierung ist nicht kanonisch. Die Reihenfolge der Schlüssel ändert sich, sobald eine Map in den meisten Sprachen einmal hin und zurück läuft. Leerzeichen tauchen auf oder verschwinden. Ein Serialisierer maskiert Nicht-ASCII-Zeichen als \uXXXX, der nächste gibt sie wörtlich aus. Zahlen kommen neu formatiert zurück, aus 1516239022 kann also 1516239022.0 werden. Jeder dieser Punkte erzeugt eine andere base64url-Zeichenkette, damit eine andere Signatureingabe und damit eine andere Signatur.
Auslöser, die uns in der Praxis begegnet sind:
- Ein API-Gateway, das das JWT parst, um es um eine Mandanten-ID anzureichern, und das Token neu ausgibt.
- Eine Logging- oder Tracing-Middleware, die Header „normalisiert“ und den Wert von Authorization umschreibt.
- Ein Entwickler, der ein Token zum Lesen formatiert und die formatierte Fassung anschließend zurückkopiert hat.
Wenn irgendeine Komponente zwischen Signierer und Prüfer das Token umschreiben kann, ist diese Komponente der erste Verdächtige. Tokens sind unterwegs undurchsichtige Zeichenketten; die einzigen unbedenklichen Operationen sind speichern, kopieren und vergleichen.
3. Gleiches Secret, andere Bytes
Diese Ursache behandelt praktisch kein Troubleshooting-Artikel, und sie steckt hinter jedem Bugreport, der mit „das Secret ist wortwörtlich identisch, ich habe es diffen lassen“ anfängt.
HMAC verarbeitet keine Zeichenkette. Er verarbeitet Bytes. Ihre Konfigurationsdatei, Ihr Secrets-Manager und Ihre Umgebungsvariablen speichern allesamt Zeichenketten. Irgendetwas muss das eine in das andere umwandeln, und diese Umwandlung ist über die JWT-Bibliotheken hinweg nicht standardisiert. Zwei Dienste können Zeichen für Zeichen identische Secrets halten und trotzdem verschiedene Signaturen berechnen.
Der Beweis, lokal gegen die Signatureingabe aus Abschnitt 2 gerechnet. Die Secret-Zeichenkette hat 36 Zeichen:
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| Byte-Interpretation | Bytes | Was der Schlüssel tatsächlich ist | Resultierende HS256-Signatur |
|---|---|---|---|
| Als UTF-8-Text behandelt | 36 | genau diese 36 sichtbaren Zeichen | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| Zuerst base64-dekodiert | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
Dieselbe Secret-Zeichenkette, derselbe Algorithmus, derselbe Payload, und trotzdem zwei Signaturen ohne jede Gemeinsamkeit. Die Seite, die es „falsch“ verstanden hat, meldet invalid signature, und kein noch so gründliches Diff der Konfigurationsdatei bringt etwas zutage, weil die Konfigurationsdateien übereinstimmen.
Das vollständige Token für die UTF-8-Lesart, falls Sie das nachstellen möchten:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Fügen Sie es zusammen mit dem Secret von oben in den JWT-Dekodierer ein, und die Signatur stimmt. Dekodieren Sie das Secret vorher als Base64, stimmt sie nicht.
Wie die einzelnen Bibliotheken einen String in Schlüsselbytes umwandeln
Halten Sie sich an das, was dokumentiert ist. Die Tabelle bleibt bewusst schmal, und die letzte Spalte ist wichtiger als die erste.
| Laufzeit / Bibliothek | Verhalten bei String zu Bytes | Wer entscheidet |
|---|---|---|
Node jsonwebtoken | UTF-8-Bytes der Zeichenkette | die Bibliothek |
Python PyJWT | UTF-8-Bytes der Zeichenkette | die Bibliothek |
Java jjwt, alte String-Überladung | plattformabhängiger Base64-Codec, siehe jwtk/jjwt#204 | die Bibliothek |
Go golang-jwt | nimmt direkt []byte entgegen | Sie, an der Aufrufstelle |
| .NET | nimmt direkt byte[] entgegen | Sie, an der Aufrufstelle |
Die Java-Zeile ist die historische Schmerzquelle über Technologiegrenzen hinweg, und die Formulierung muss hier genau sein. In alten jjwt-Versionen schickten signWith(SignatureAlgorithm, String) und verwandte Methoden den String durch einen Base64-Codec, statt seine Rohbytes zu nehmen, während die byte[]-Überladungen die Bytes unverändert verwendeten. Ein Node-Dienst und ein Java-Dienst, die sich ein Secret teilten, waren sich deshalb uneinig. Diese String-API gilt seit jjwt 0.10 als veraltet, und die moderne Form ist explizit:
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
Das ist nicht „so macht Java JWTs“. Es ist eine Alt-Überladung einer einzelnen Bibliothek, und aktueller jjwt-Code, der ein byte[] übergibt, ist eindeutig. Das Spiegelbild auf der Node-Seite ist auth0/node-jsonwebtoken#208, wo in Java signierte Tokens sich in Node nicht verifizieren ließen. Vergleichbare Meldungen gibt es zu PHPs firebase/php-jwt (siehe firebase/php-jwt#153), allerdings haben wir das Byte-Verhalten dieser Bibliothek nicht selbst überprüft, behandeln Sie das also als Spur und nicht als Diagnose.
Go und .NET gehören in eine andere Schublade. Keine der beiden Bibliotheken entscheidet für Sie; beide reichen Ihnen den Parameter []byte bzw. byte[] und treten einen Schritt zurück. []byte(secret) und Encoding.UTF8.GetBytes(secret) liefern UTF-8, Convert.FromBase64String(secret) liefert dekodierte Bytes. Der Fehler sitzt hier, wenn er auftritt, in Ihrer eigenen Aufrufstelle. Das ist die gute Nachricht, denn dort ist er im Diff sichtbar.
Ist mein JWT-Secret Base64 oder UTF-8?
Es gibt kein Flag im Token, das Ihnen das verrät. Sie müssen über die Zeichenkette selbst nachdenken:
- Verwendet sie ausschließlich
A–Z a–z 0–9 + / =(oder-und_)? Wenn ja, könnte sie Base64 sein. Ein Secret, das ein Leerzeichen, ein!oder ein#enthält, kann es nicht sein. - Ist ihre Länge ein Vielfaches von 4, oder endet sie auf
=-Padding? Beides sind starke Hinweise darauf, dass etwas sie auf dem Weg hinein base64-kodiert hat. - Ergibt eine Base64-Dekodierung sinnvolle Bytes? Schicken Sie sie durch den Base64-Dekodierer. Lesbares ASCII oder exakt 32 zufällig aussehende Bytes sprechen für Base64. Zeichensalat spricht dafür, dass die Zeichenkette nie kodiert wurde.
Ein Secret wie c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm erfüllt alle drei Kriterien, und genau das macht es gefährlich: Es ist mehrdeutig, und beide Lesarten sind plausibel. Secrets mit einem - oder _ darin sind auf eine unangenehmere Weise mehrdeutig, denn sie sind gültiges base64url, aber ungültiges Standard-Base64.
Wenn Sie sich die Antwort nicht erschließen können, berechnen Sie einfach beides. Nehmen Sie die Signatureingabe, jagen Sie sie im HMAC-Generator zweimal durch HMAC-SHA256, einmal mit dem Secret als Text und einmal mit den dekodierten Bytes, und vergleichen Sie beide Ergebnisse mit dem dritten Segment des Tokens. Eines davon wird passen, und das sagt Ihnen, welche Seite Ihres Systems recht hat.
Zeichen sind keine Bytes
Die verwandte Falle ist, Zeichen zu zählen, wo die Anforderung in Bytes formuliert ist. RFC 7518 §3.2 gibt die Untergrenze für Schlüssel bei HMAC-SHA in Bits an, nicht in Zeichen, und kodierter Text bläht sich auf:
| Schreibweise | Entropie | Entsprechende Bytes | Für HS256 (braucht ≥256 bit) |
|---|---|---|---|
| 32 Hex-Zeichen | 128 bit | 16 Bytes | unter der Untergrenze |
| 32 Base64-Zeichen | 192 bit | 24 Bytes | unter der Untergrenze |
| 32 Zufallsbytes | 256 bit | 32 Bytes | erfüllt sie (64 Zeichen in Hex, 44 in Base64 mit Padding) |
Ein „32 Zeichen langes Secret“ kann je nach Alphabet zwischen 128 und 256 Bit liegen. Das ist orthogonal zum Byte-Interpretationsproblem weiter oben, erwischt aber dieselben Leute, denn ein Team, das in Zeichen misst, ist üblicherweise ein Team, das nie auf die Bytes geschaut hat. Die eigentlichen Auswahlregeln (Länge, Wahl der Kodierung, Rotation) behandelt der Referenzteil des JWT-Secret-Generators.
4. Das Secret selbst wurde verunreinigt
Ihre beiden Dienste sind sich über die Byte-Interpretation einig. Die Signatur scheitert trotzdem. Prüfen Sie jetzt, ob das Secret, das jede Seite geladen hat, wirklich das Secret ist, das Sie geschrieben zu haben glauben. Die Umgebungsverdrahtung ist bemerkenswert gut darin, ein Byte hinzuzufügen.
Ein Zeilenumbruch am Ende der .env reicht schon. JWT_SECRET=abc gefolgt von einem Zeilenumbruch kann von manchen Readern als abc\n geladen werden. Ein einziges zusätzliches Byte, und HMAC liefert eine völlig unverwandte Ausgabe. Es gibt keine teilweise Ähnlichkeit, die Ihnen auffallen könnte.
Anführungszeichen landen manchmal in den Daten. JWT_SECRET="abc" bedeutet für manche Loader abc und für andere "abc", besonders wenn die Datei einmal von einer Shell gesourct und einmal von einer Bibliothek geparst wird. Das env_file von Docker Compose und ein .env-Parser können sich über dieselbe Datei uneinig sein.
Unsichtbare Zeichen kommen per Copy-and-paste herein. Wer ein Secret aus Slack, einem Wiki oder einem PDF kopiert, schleppt womöglich ein Zero-Width-Space (U+200B, Bytes e2 80 8b) oder ein geschütztes Leerzeichen (U+00A0, Bytes c2 a0) mit. Beide sind in jedem Editor unsichtbar, und beide verändern den HMAC.
CI und Container verstümmeln Werte auf ihre eigene Weise. Läuft ein Secret durch Shell-Interpolation, expandiert $ oder ein Backslash verschwindet. Manche CI-Systeme trimmen Werte, andere nicht. Kubernetes-Secrets liegen im Manifest als Base64 und im Container roh vor, eine Doppeldekodierungsfalle für sich.
Hören Sie also auf, das Secret anzuschauen, und vermessen Sie es. Geben Sie auf jeder Seite die Länge und einen Fingerabdruck aus, niemals den Wert:
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
Führen Sie beide Befehle auf dem Signierer und auf dem Prüfer aus und vergleichen Sie die beiden Ausgaben. Gleiche Länge und gleicher Fingerabdruck heißt, dass das Secret nicht Ihr Problem ist; gehen Sie dann zurück zu Abschnitt 3. Eine Länge, die um eins größer ist als erwartet, ist der abschließende Zeilenumbruch. Um zwei größer sind die Anführungszeichen.
Wenn die Länge nicht stimmt und Sie genau sehen wollen, was da drinsteckt, machen Sie in einer lokalen Shell einen Hexdump gegen ein Entwicklungs-Secret:
printf '%s' "$JWT_SECRET" | xxd
Ein 0a am Ende ist ein Zeilenumbruch. Ein 22 am Anfang und am Ende ist ein Paar Anführungszeichen. c2 a0 oder e2 80 8b mittendrin ist der Fall mit den unsichtbaren Zeichen. Führen Sie das nicht gegen ein Produktions-Secret auf einer Maschine aus, die ihre Terminalausgabe irgendwohin verschickt.
Die entsprechende Prüfung innerhalb eines laufenden Node- oder Python-Prozesses:
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:]))
Wenn in Python len(s) einen kleineren Wert zählt als len(s.encode("utf-8")), dann stecken in einem Secret, das eigentlich ASCII sein sollte, Nicht-ASCII-Zeichen.
5. Algorithmus und Schlüsseltyp passen nicht zusammen
Der Header alg und der Schlüssel, den Sie übergeben, müssen zur selben Familie gehören. HS256 will ein gemeinsames Secret, also eine Bytefolge. RS256 und ES256 wollen einen asymmetrischen Schlüssel, also ein PEM oder ein JWK. Wenn Sie diese Leitungen kreuzen, bekommen Sie Fehlschläge, die je nach Nachsicht der Bibliothek von einem klaren Typfehler bis zu einem schlichten invalid signature reichen.
Gängige Ausprägungen davon:
- Der Header sagt
HS256, und der Prüfer übergibt der Bibliothek einen öffentlichen PEM-Schlüssel. Manche Bibliotheken bilden den HMAC über den PEM-Text und melden eine nicht passende Signatur. - Der Header sagt
RS256, und der Prüfer übergibt die HMAC-Secret-Zeichenkette. - Der Prüfer übergibt überhaupt keine Algorithmenliste und lässt die Bibliothek aus
algschließen, sodass eine Konfigurationsdrift auf der Signaturseite stillschweigend ändert, was der Prüfer tut.
Der letzte Punkt ist der, an dem aus einem Konfigurationsfehler ein Sicherheitsfehler wird, legen Sie den Algorithmus also bei jedem Verify-Aufruf explizit fest:
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
Das Festlegen verwandelt außerdem vage Signaturfehler in präzise. Trifft ein Token mit alg: RS256 ein und Ihre Allowlist sagt HS256, bekommen Sie einen expliziten Algorithmusfehler, der beide Werte benennt.
Alles, was dieser Abschnitt beschreibt, ist eine Fehlkonfiguration: zwei Ihrer eigenen Komponenten, die sich uneinig sind, ganz ohne Angreifer. Es gibt einen verwandten Fehlschlag derselben Form, bei dem ein Angreifer alg von RS256 auf HS256 umschreibt und mit Ihrem öffentlichen Schlüssel als HMAC-Secret signiert. Das ist Algorithmus-Confusion, ein Angriff und kein Bug; JWT-Sicherheit: Best Practices behandelt sie zusammen mit dem restlichen Bedrohungsmodell. Die Abwehr ist zufällig dieselbe, nämlich eine explizite Allowlist, und das ist ein gutes Argument dafür, sie auch dann anzuwenden, wenn Sie nur einem Bug hinterherjagen.
6. Das Token hat sich auf dem Transportweg verändert
Bevor Sie den Schlüsseln die Schuld geben, vergewissern Sie sich, dass der Prüfer dieselbe Zeichenkette erhalten hat, die der Signierer erzeugt hat. Ein JWT ist genau auf die Weise fragil, auf die Zeichenketten fragil sind.
Das Präfix Bearer gehört nicht zum Token. Authorization: Bearer eyJhbGci... ist ein Header-Wert, kein Token. Wer am falschen Zeichen trennt oder nur einmal trennt und die falsche Hälfte behält, verifiziert am Ende Bearer eyJhbGci... oder eine leere Zeichenkette. Entfernen Sie es bewusst:
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
Leerzeichen und Zeilenumbrüche kommen über Umwege herein. Ein Terminal bricht kopierte Tokens um, YAML faltet abgelegte Tokens. Ein einzelnes eingebettetes \n im dritten Segment erzeugt eine nicht passende Signatur und keinen Parse-Fehler, denn base64url-Dekodierer überspringen Leerraum häufig, der Zeichenkettenvergleich aber nicht.
Auch die URL-Kodierung mischt mit. Ein Token, das als Query-Parameter unterwegs war, kommt womöglich mit . als %2E zurück oder mit - und _, die ein übereifriger Encoder übersetzt hat. Dekodieren Sie einmal, genau einmal.
Bleibt das Abschneiden. Cookies sind bei jeweils rund 4 KB gedeckelt, und RS256-Tokens mit ein paar Claims überschreiten das regelmäßig. Ein abgeschnittenes Token scheitert meist schon an der Base64-Dekodierung, aber wenn der Schnitt auf einer 4-Zeichen-Grenze liegt, bekommen Sie stattdessen ein gültig aussehendes Token mit falscher Signatur.
Zwei Befehle klären das. Ein wohlgeformtes JWT hat genau zwei Punkte:
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
Und jedes Zeichen muss im base64url-Alphabet liegen, dieser Befehl sollte also gar nichts ausgeben:
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
Jede Ausgabe des zweiten Befehls benennt Ihr Problem: 3d ist =-Padding, das dort nicht hingehört, 2b oder 2f sind das + und / aus Standard-Base64 an Stellen, an denen base64url - und _ erwartet, und 20 ist ein verirrtes Leerzeichen.
7. Spezifische Fehler bei RS256 und ES256
Asymmetrische Algorithmen tauschen das Secret-Problem gegen ein Schlüsselverwaltungsproblem. Die Fehlerbilder sehen entsprechend anders aus.
PKCS#1 und PKCS#8 sind zwei Containerformate für denselben RSA-Schlüssel, und sie lassen sich an einem einzigen Wort in der Kopfzeile optisch unterscheiden:
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
Bibliotheken unterscheiden sich darin, welches sie akzeptieren. Weist eine das Format rundheraus zurück, bekommen Sie einen klaren Fehler; parst sie es halb, kann eine Signatur herauskommen, die sich nie verifizieren lässt. Konvertieren Sie lieber, statt dagegen anzukämpfen:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
Die Schlüssel können auch schlicht vertauscht sein: mit dem öffentlichen signiert oder mit dem privaten geprüft. Im Prinzip offensichtlich, in der Praxis leicht passiert, wenn beide Dateien im selben Verzeichnis liegen und sich die Namen um vier Zeichen unterscheiden. Prüfen Sie nach, was was ist:
openssl rsa -in key.pem -noout -text | head -1
Ein privater Schlüssel gibt seine Modulusgröße als privater Schlüssel aus; ein öffentlicher Schlüssel läuft auf einen Fehler, solange Sie nicht -pubin ergänzen.
Dazu kommt die kid-Drift. Bei einem JWKS-Endpunkt sucht sich der Prüfer einen Schlüssel, indem er die kid des Tokens gegen das Schlüsselset abgleicht. Drei Dinge gehen hier schief: Der Signierer hat rotiert und das gecachte JWKS des Prüfers ist veraltet; das Token hat keine kid und der Prüfer nimmt den ersten Schlüssel im Set; oder zwei Umgebungen veröffentlichen überlappende kid-Werte. Wenn Sie das vermuten, holen Sie das JWKS frisch und bestätigen Sie, dass die exakte kid aus dem Token-Header darin enthalten ist.
Zuletzt die Signaturkodierung bei ES256. ECDSA-Signaturen sind ein Paar von Ganzzahlen, r und s, und es gibt zwei Arten, sie zu serialisieren. Allgemeine Krypto-Stacks geben oft DER aus, eine ASN.1-Struktur variabler Länge. RFC 7518 §3.4 verlangt stattdessen die JOSE-Form: r und s jeweils auf eine feste Länge aufgefüllt und aneinandergehängt, was bei P-256 64 Bytes ergibt. Eine DER-Signatur, die in ein JWT fällt, ist falsch und hat obendrein eine andere Länge. Ein ES256-Token, dessen drittes Segment sich nicht zu exakt 64 Bytes dekodieren lässt, wurde also von etwas gebaut, das die Umwandlung übersprungen hat.
Um einzugrenzen, ob das Problem an Ihrem Schlüssel oder an Ihrer Pipeline liegt, signieren Sie denselben Payload unabhängig im JWT-Kodierer und vergleichen Sie die Ausgabe mit dem, was Ihr Dienst erzeugt hat. Identische Signaturen deuten auf Transport oder Claim-Verarbeitung hin. Unterschiedliche Signaturen deuten auf den Schlüssel.
8. Fehler, die wie Signaturfehler aussehen, es aber nicht sind
Manche davon benennen die Bibliotheken selbst falsch, und genau so landen sie im falschen Bugreport.
| Symptom | Was es tatsächlich ist | Wo Sie nachsehen sollten |
|---|---|---|
PyJWT ExpiredSignatureError | exp liegt in der Vergangenheit. Der Name sagt Signatur, die Ursache ist ein Claim. | Uhrenversatz zwischen den Hosts oder eine zu kurze TTL |
PyJWT ImmatureSignatureError | nbf liegt in der Zukunft | Die Uhr des Signierers geht der des Prüfers voraus |
Node TokenExpiredError | exp liegt in der Vergangenheit | wie oben |
| Generischer 401 ohne Details | Das Framework hat jeden Prüffehlschlag in eine einzige Antwort zusammengefasst | Fehler-Logging auf Bibliotheksebene einschalten |
| Läuft ein paar Minuten, dann nicht mehr | Ablauf des Tokens, nicht die Signatur | iat und exp gegen die Uhren beider Hosts vergleichen |
| Scheitert nur bei einer Audience | aud oder iss passt nicht | Die erwartete Audience-Liste des Prüfers |
Die Benennung bei PyJWT ist die auffälligste Falle. ExpiredSignatureError enthält das Wort „Signature“, wird aber erst während der Claim-Prüfung geworfen, lange nachdem die Signatur bereits erfolgreich verifiziert wurde. Wer nach dem Fehlerstring sucht, landet direkt in Material zur Signaturfehlersuche, und dann verschwinden Stunden im falschen Teil des Problems.
Uhrenversatz erzeugt das verwirrendste Muster von allen: sporadische Fehlschläge, die mit nichts in Ihrem Code korrelieren. Geht die Uhr eines Hosts vor, scheitern frisch ausgestellte Tokens bei Ankunft an der Prüfung von nbf oder iat, und die Fehlschläge wandern, während die Abweichung wächst. Vergleichen Sie zuerst date -u auf beiden Maschinen. Die meisten Bibliotheken akzeptieren einen Toleranzparameter, und der ist die richtige Lösung für einen Versatz, den Sie nicht beseitigen können, und die falsche für eine Uhr, die wirklich kaputt ist.
Die allgemeine Regel: Wenn der Fehlschlag von der Zeit, vom Host oder von der Audience abhängt, ist es kein Signaturproblem. Signaturfehler sind deterministisch. Dasselbe Token mit demselben Schlüssel scheitert für immer auf dieselbe Weise.
9. Ein wiederholbarer Workflow zur Fehlersuche
Arbeiten Sie diese Schritte der Reihe nach ab. Jeder Schritt findet entweder den Fehler oder streicht einen Zweig, und früh aufhören zu können ist der ganze Sinn der Sache.
- Dekodieren Sie den Header. Fügen Sie das Token in den JWT-Dekodierer ein und notieren Sie
algundkid. Das entscheidet alles Weitere und braucht keinen Schlüssel. - Prüfen Sie die Form des Tokens. Genau zwei Punkte, ausschließlich base64url-Zeichen, kein
Bearer-Präfix, kein Leerraum. Nutzen Sie die beiden Befehle aus Abschnitt 6. Damit fällt Transportkorruption weg. - Legen Sie den Algorithmus am Verify-Aufruf fest. Wenn es eine Abweichung zwischen
algund Ihrer Allowlist gibt, bekommen Sie ab jetzt einen expliziten Fehler, der beide benennt, statt eines generischen. - Nehmen Sie auf beiden Seiten einen Fingerabdruck des Schlüssels. Geben Sie Bytelänge und einen gekürzten SHA-256 auf dem Signierer und auf dem Prüfer aus, wie in Abschnitt 4. Unterschiedliche Werte heißen, dass die Verdrahtung schuld ist, und Sie kommen gar nicht erst zu Schritt 5.
- Wenn die beiden Seiten verschiedene Sprachen sind, klären Sie die Byte-Interpretation. Ziehen Sie die Tabelle aus Abschnitt 3 heran, entscheiden Sie explizit, ob das Secret Text oder Base64 ist, und lassen Sie beide Seiten das im Code aussprechen statt per Standardverhalten.
- Signieren Sie denselben Payload unabhängig erneut. Nutzen Sie den JWT-Kodierer mit dem Schlüssel, den Sie für richtig halten, und vergleichen Sie dessen drittes Segment mit dem Ihres Tokens. Stimmt es überein, ist Ihre Signaturseite in Ordnung und der Prüfer ist das Problem.
- Rechnen Sie den HMAC von Hand gegen. Schicken Sie die Signatureingabe mit beiden Byte-Interpretationen durch den HMAC-Generator. Diejenige, die zum Token passt, sagt Ihnen, welche Seite Sie ändern müssen.
Wenn Sie alle sieben Schritte durchhaben und immer noch Hilfe brauchen: Die meisten Bugreports bleiben liegen, weil sie genau die Fakten weglassen, die die Antwort bestimmen. Legen Sie das hier bei:
- Den Wert
algaus dem Header und ob einekidvorhanden ist - Sprache, Bibliothek und exakte Version auf beiden Seiten, der signierenden und der prüfenden
- Die Bytelänge des Secrets auf beiden Seiten und die ersten 16 Hex-Zeichen seines SHA-256 (niemals das Secret selbst)
- Ob das Secret als Text oder als Base64 gespeichert ist und wie jede Seite es umwandelt
- Die vollständige Signatureingabe. Die ersten beiden Segmente sind nicht sensibel; wer das Token hat, kann sie ohnehin lesen
- Für RS256 und ES256: die PEM-Kopfzeile, wortwörtlich
Diese Liste macht aus einem unbeantwortbaren „meine JWT-Signatur stimmt nicht“ eine Frage, die jemand tatsächlich klären kann, meist in einer einzigen Antwort.
FAQ
Warum funktioniert dasselbe Secret in einer Sprache, in einer anderen aber nicht?
Weil Bibliotheken sich darüber uneinig sind, wie eine Secret-Zeichenkette zu Schlüsselbytes wird. Node jsonwebtoken und Python PyJWT verwenden UTF-8; die alte String-Überladung von jjwt nutzte einen Base64-Codec (jwtk/jjwt#204); Go und .NET überlassen die Entscheidung Ihrer Aufrufstelle. Gleiche Zeichen, andere Bytes, anderer HMAC.
Deckt die Signatur den dekodierten Payload ab oder die kodierte Zeichenkette?
Die kodierte Zeichenkette. RFC 7515 definiert die Signatureingabe als base64url(header) + "." + base64url(payload) in wörtlichem ASCII. Jede Schicht, die den Payload deserialisiert und neu serialisiert, verändert Schlüsselreihenfolge, Leerzeichen oder Zahlenformatierung und erzeugt damit eine andere Zeichenkette und folglich eine andere Signatur.
Mein Secret sieht nach Base64 aus — soll ich es vor dem Signieren dekodieren?
Nur wenn die Gegenseite das auch tut. Isoliert betrachtet gibt es keine richtige Antwort; die Anforderung lautet, dass beide Enden sich einig sind. Prüfen Sie, ob die Zeichenkette nur Base64-Zeichen verwendet und ihre Länge ein Vielfaches von vier ist, und treffen Sie die Entscheidung dann auf beiden Seiten explizit im Code, statt sich auf Standardverhalten zu verlassen.
Kann ein Zeilenumbruch am Ende der .env-Datei die Signatur wirklich zerstören?
Ja. HMAC verarbeitet Bytes, und abc\n sind vier Bytes, wo abc drei sind. Die entstehende Signatur hat mit der richtigen nichts gemeinsam. Geben Sie auf beiden Hosts printf '%s' "$JWT_SECRET" | wc -c aus; eine Länge, die um eins größer ist als erwartet, ist fast immer genau das.
Woran erkenne ich, ob es am Secret oder am Algorithmus liegt?
Lesen Sie zuerst alg aus dem Header. Beginnt er mit HS, brauchen Sie ein gemeinsames Secret, und ein PEM wird scheitern. Beginnt er mit RS, PS oder ES, brauchen Sie ein Schlüsselpaar, und eine Secret-Zeichenkette wird scheitern. Sobald alg und Schlüsseltyp zur selben Familie gehören, sind die verbleibenden Fehlschläge Probleme mit dem Schlüsselinhalt.
Warum sagt jwt.io, die Signatur sei gültig, mein Server lehnt sie aber ab?
Weil das Online-Werkzeug und Ihr Server das Secret unterschiedlich interpretieren können: das eine als UTF-8-Text, das andere als Base64. Das Werkzeug prüft gegen die Bytes, die es abgeleitet hat, nicht gegen die Bytes, die Ihr Server abgeleitet hat. Fügen Sie außerdem niemals Produktions-Secrets in eine fremde Website ein; nehmen Sie einen Entwicklungsschlüssel.
Kann „invalid signature“ durch ein abgelaufenes Token verursacht werden?
Nein. Die Signaturprüfung läuft vor der Claim-Prüfung, Ablauf kann also nie die Ursache sein. Der Ablauf zeigt sich getrennt als TokenExpiredError in Node oder ExpiredSignatureError in PyJWT. Der Name des Letzteren führt in die Irre, denn die Signatur wurde einwandfrei verifiziert und lediglich exp ist durchgefallen.
Fazit
Nicht passende Signaturen sind fast nie ein Kryptografieproblem. HMAC-SHA256 funktioniert. RSA funktioniert. Was scheitert, ist die Grenze, an der aus einer Zeichenkette Bytes werden: ein Base64-Codec auf der einen Seite und UTF-8 auf der anderen, ein Zeilenumbruch, den ein Konfigurationslader behalten hat, ein Payload, den ein Gateway hilfsbereit neu serialisiert hat. Jede Ursache in diesem Leitfaden ist eine Uneinigkeit über Bytes.
Machen Sie die Bytes also explizit und hören Sie auf, sich auf Standardverhalten zu verlassen. Halten Sie in der Dokumentation Ihres Teams schriftlich fest, ob das gemeinsame Secret als Rohtext oder als Base64 abgelegt ist, und lassen Sie jeden Dienst es auf die deklarierte Weise umwandeln, statt zu erben, was seine Bibliothek eben angenommen hat. Bei Systemen über mehrere Sprachen hinweg legen Sie Secrets in Hex oder Base64 ab und dekodieren sie an jeder Aufrufstelle explizit: eine Zeile pro Dienst, und die Mehrdeutigkeit ist weg. Nehmen Sie dann den Bytelängen-Fingerabdruck aus Abschnitt 4 in Ihren Health-Check auf, damit die nächste Abweichung als Startwarnung auftaucht und nicht als 401 in Produktion.