Skip to content
Powrót do bloga
Bezpieczeństwo

JWT „invalid signature”: wszystkie przyczyny i sposoby naprawy

Nieprawidłowy podpis JWT (invalid signature): różne bajty klucza zależnie od języka, znak nowej linii w .env, niezgodny algorytm. Darmowy dekoder.

14 min czytania

JWT „invalid signature”: wszystkie przyczyny i sposoby naprawy

Błąd invalid signature w JWT znaczy dokładnie jedno: podpis policzony po stronie weryfikującej nie jest równy podpisowi, który niesie token. To cała treść tego komunikatu. Nie znaczy, że token wygasł ani że użytkownikowi brakuje uprawnień, i prawie nigdy nie jest to błąd w bibliotece JWT. Coś w bajtach wchodzących do HMAC albo w kluczu publicznym podanym do wywołania weryfikującego różni się między stroną, która podpisała, a stroną, która sprawdza.

W większości przypadków winny jest materiał klucza, a nie token. Punkt startowy wybiera się tak:

Jaki algorytm jest w nagłówku?
├─ HS256 / HS384 / HS512  → prawie zawsze problem z sekretem
│    ├─ strona podpisująca i weryfikująca w różnych językach? → sekcja 3
│    └─ ten sam język, lokalnie działa, na produkcji nie?     → sekcja 4
└─ RS256 / ES256 / PS256  → prawie zawsze format klucza albo zły klucz
     └─ → sekcja 7

Token szedł przez gateway, proxy albo kopiuj-wklej?             → sekcja 6
Błąd wychodzi dopiero po kilku godzinach albo na jednym hoście? → sekcja 8

Każda sekcja poniżej kończy się czymś, co da się uruchomić. Na pierwszy ruch wystarczy wkleić token do dekodera JWT i odczytać pole alg, bo połowa powyższych gałęzi znika w momencie, w którym ta wartość jest znana.

1. Co naprawdę znaczy invalid signature

Różne biblioteki wypisują dla tej samej awarii różne komunikaty. Najpierw trzeba odszukać swój na tej liście, żeby mieć pewność, że to właściwy przewodnik:

  • 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.

Wszystkie trzy odpalają w tym samym momencie, w tym samym miejscu kodu. Biblioteka bierze dwa pierwsze segmenty tokenu, przelicza podpis kluczem, który dostała, i porównuje wynik bajt po bajcie z trzecim segmentem. Nie są równe — leci wyjątek.

Porównanie jest dokładne i nie niesie żadnej informacji o tym, jak bardzo obie wartości się różnią. Jeden bajt różnicy w sekrecie i całkowicie zły klucz dają identyczny komunikat. Właśnie dlatego reszta tego przewodnika polega na zawężaniu przestrzeni wejść, a nie na uważniejszym czytaniu błędu.

W chwili tego błędu sporo rzeczy jeszcze się nie wydarzyło. Walidacja claims biegnie po weryfikacji podpisu, więc exp, nbf, aud ani iss nie zostały jeszcze obejrzane. Jeśli weryfikacja podpisu JWT się nie powiodła, zawartość tokenu jest dla diagnozy nieistotna — choć nadal daje się ją odczytać, bo JWT jest zakodowany, a nie zaszyfrowany. Zdekodowanie nagłówka i payloadu nie wymaga żadnego klucza; opis segment po segmencie znajduje się w tekście jak zdekodować JWT token.

O tym, dokąd iść dalej, decydują dwa pola nagłówka: alg mówi, czy poluje się na współdzielony sekret, czy na parę kluczy, a kid mówi, którego klucza w swoim mniemaniu używała strona podpisująca.

2. Podpis obejmuje zakodowany ciąg, a nie obiekt

Ten model myślowy większość programistów ma odwrócony. RFC 7515, specyfikacja JSON Web Signature, definiuje dane wejściowe podpisu (JWS Signing Input) jako ciąg ASCII:

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

HMAC liczy się z tego ciągu, a nie z mapy claims, obiektu JSON czy czegokolwiek, co dany język uważa za dane strukturalne. Oto signing input używany w całym tym artykule, wzięty ze standardowego przykładowego payloadu:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Konsekwencja jest prosta i regularnie wykłada zespoły: każda warstwa, która dekoduje payload i koduje go z powrotem, niszczy podpis. Serializacja JSON nie jest kanoniczna. Kolejność kluczy zmienia się, gdy mapa przejdzie tam i z powrotem przez większość języków. Białe znaki się pojawiają albo znikają. Znaki spoza ASCII jeden serializator escapuje jako \uXXXX, a drugi wypisuje dosłownie. Liczby zostają przeformatowane, więc 1516239022 potrafi wrócić jako 1516239022.0. Każda z tych rzeczy daje inny ciąg base64url, a więc inny signing input, a więc inny podpis.

Realne wyzwalacze, które widzieliśmy:

  • Gateway API, który parsuje JWT, żeby wzbogacić go o identyfikator tenanta, i emituje token na nowo.
  • Middleware logujące albo tracujące, które „normalizuje” nagłówki i przepisuje wartość Authorization.
  • Programista, który sformatował token, żeby go przeczytać, a potem wkleił z powrotem wersję sformatowaną.

Jeśli jakikolwiek komponent między stroną podpisującą a weryfikującą może przepisać token, to on jest pierwszym podejrzanym. W tranzycie token jest nieprzezroczystym ciągiem znaków; jedyne bezpieczne operacje to zapisać, skopiować i porównać.

3. Ten sam sekret, inne bajty

Tę przyczynę pomija prawie każdy poradnik, a stoi ona za zgłoszeniami w stylu „sekret jest dosłownie identyczny, zrobiłem diff”.

HMAC nie konsumuje ciągu znaków. Konsumuje bajty. Plik konfiguracyjny, menedżer sekretów i zmienne środowiskowe przechowują ciągi znaków. Coś musi zamienić jedno w drugie, a ta konwersja nie jest ujednolicona między bibliotekami JWT. Dwie usługi mogą trzymać sekrety identyczne znak w znak i mimo to policzyć różne podpisy.

Oto dowód, policzony lokalnie na signing input z sekcji 2. Ciąg sekretu ma 36 znaków:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Interpretacja bajtówBajtyCzym klucz naprawdę jestWynikowy podpis HS256
Potraktowany jako tekst UTF-836te same 36 widocznych znakówtUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Najpierw zdekodowany z base6427secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Ten sam ciąg sekretu, ten sam algorytm, ten sam payload, a podpisy nie mają ze sobą nic wspólnego. Ta strona, która „pomyliła się”, zgłasza invalid signature, a żadne porównywanie plików konfiguracyjnych niczego nie ujawni, bo pliki konfiguracyjne się zgadzają.

Pełny token dla odczytu UTF-8, gdyby ktoś chciał to odtworzyć:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Wystarczy wkleić go do dekodera JWT razem z powyższym sekretem i weryfikacja przechodzi. Zdekodować sekret najpierw z base64 i już nie przechodzi.

Jak poszczególne biblioteki zamieniają ciąg znaków na bajty klucza

Liczy się tylko to, co udokumentowane. Poniższa tabela jest celowo wąska, a ostatnia kolumna znaczy więcej niż pierwsza.

Środowisko / bibliotekaZamiana ciągu na bajtyKto decyduje
Node jsonwebtokenbajty UTF-8 z ciągubiblioteka
Python PyJWTbajty UTF-8 z ciągubiblioteka
Java jjwt, przestarzałe przeciążenie na Stringplatformowy kodek base64, zgodnie z jwtk/jjwt#204biblioteka
Go golang-jwtprzyjmuje []byte wprostty, w miejscu wywołania
.NETprzyjmuje byte[] wprostty, w miejscu wywołania

Wiersz o Javie to historyczne źródło bólu między stosami technologicznymi i wymaga precyzyjnego opisu. W starych wersjach jjwt metoda signWith(SignatureAlgorithm, String) i jej krewne przepuszczały String przez kodek base64, zamiast brać jego surowe bajty, podczas gdy przeciążenia na byte[] używały bajtów takich, jakie dostały. Usługa w Node i usługa w Javie współdzielące jeden sekret rozjeżdżały się więc na starcie. To API na String jest oznaczone jako przestarzałe od jjwt 0.10, a współczesna forma jest jawna:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

To nie jest „tak Java robi JWT”. To jedno przestarzałe przeciążenie w jednej bibliotece, a współczesny kod jjwt, który podaje byte[], nie ma tu żadnej dwuznaczności. Lustrzane zgłoszenie po stronie Node to auth0/node-jsonwebtoken#208, gdzie tokeny podpisane w Javie nie przechodziły weryfikacji w Node. Podobne zgłoszenia istnieją wobec PHP-owego firebase/php-jwt (zobacz firebase/php-jwt#153), choć obsługi bajtów w tej bibliotece sami nie zweryfikowaliśmy, więc lepiej traktować to jako trop, a nie diagnozę.

Go i .NET należą do innego worka. Żadna z tych bibliotek nie decyduje za nikogo; obie podają parametr []byte / byte[] i się wycofują. []byte(secret) oraz Encoding.UTF8.GetBytes(secret) dają UTF-8, a Convert.FromBase64String(secret) daje zdekodowane bajty. Błąd, jeśli się pojawi, siedzi w miejscu wywołania — i to dobra wiadomość, bo widać go we własnym diffie.

Czy mój sekret JWT to base64, czy UTF-8?

W tokenie nie ma flagi, która by to zdradziła. Trzeba wywnioskować to z samego ciągu:

  1. Czy używa wyłącznie A–Z a–z 0–9 + / = (albo - i _)? Jeśli tak, może być base64. Sekret zawierający spację, ! albo # już nie.
  2. Czy jego długość jest wielokrotnością 4 albo kończy się dopełnieniem =? Jedno i drugie mocno sugeruje, że coś po drodze zakodowało go w base64.
  3. Czy dekodowanie z base64 daje sensowne bajty? Wystarczy przepuścić go przez dekoder Base64. Czytelne ASCII albo dokładnie 32 losowo wyglądające bajty wskazują na base64. Krzaki sugerują, że ciąg nigdy nie był kodowany.

Sekret pokroju c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm przechodzi wszystkie trzy testy i właśnie dlatego jest niebezpieczny: jest dwuznaczny, a oba odczyty są wiarygodne. Sekrety zawierające - albo _ są dwuznaczne w paskudniejszy sposób, bo stanowią poprawne base64url, ale niepoprawne standardowe base64.

Kiedy rozumowanie nie prowadzi do odpowiedzi, trzeba policzyć oba warianty. Wystarczy wziąć signing input i przepuścić go przez HMAC-SHA256 dwa razy w generatorze HMAC, raz z sekretem jako tekstem, raz ze zdekodowanymi bajtami, a potem porównać oba wyniki z trzecim segmentem tokenu. Jeden z nich się zgodzi, i to właśnie mówi, która strona systemu ma rację.

Znaki to nie bajty

Pokrewna pułapka to liczenie znaków, kiedy wymóg podany jest w bajtach. RFC 7518 §3.2 podaje dolny próg klucza dla HMAC-SHA w bitach, nie w znakach, a zakodowany tekst puchnie:

Jak to zapisanoEntropiaOdpowiednik w bajtachDla HS256 (wymaga ≥256 bitów)
32 znaki hex128 bitów16 bajtówponiżej progu
32 znaki base64192 bity24 bajtyponiżej progu
32 losowe bajty256 bitów32 bajtyspełnia go (64 znaki w hex, 44 w base64 z dopełnieniem)

„Sekret 32-znakowy” może mieć od 128 do 256 bitów, zależnie od alfabetu. To sprawa niezależna od problemu interpretacji bajtów opisanego wyżej, ale gryzie tych samych ludzi, bo zespół, który mierzy w znakach, to zwykle zespół, który nigdy nie zajrzał w bajty. Po właściwe reguły doboru (długość, wybór kodowania, rotacja) należy sięgnąć do generatora sekretów JWT; tamtejsze noty referencyjne omawiają je jak trzeba i nie ma powodu ich tutaj powielać.

4. Sam sekret uległ skażeniu

Obie usługi zgadzają się co do interpretacji bajtów. Podpis dalej nie przechodzi. Teraz trzeba sprawdzić, czy sekret wczytany po każdej ze stron to naprawdę ten, który miał zostać zapisany, bo hydraulika środowiska ma niezwykły talent do doklejania bajtów.

Końcowy znak nowej linii w .env. JWT_SECRET=abc z łamaniem wiersza pod spodem bywa wczytywane przez niektóre czytniki jako abc\n. Jeden bajt więcej i HMAC produkuje wynik zupełnie niepowiązany. Nie ma tu żadnego częściowego podobieństwa, które dałoby się zauważyć.

Cudzysłowy czytane jako dane. JWT_SECRET="abc" znaczy abc dla jednych loaderów i "abc" dla innych, zwłaszcza gdy plik jest raz sourcowany przez powłokę, a raz parsowany przez bibliotekę. env_file w Docker Compose i parser .env potrafią rozejść się na tym samym pliku.

Niewidoczne znaki z kopiuj-wklej. Skopiowanie sekretu ze Slacka, wiki albo PDF-a potrafi przyciągnąć spację o zerowej szerokości (U+200B, bajty e2 80 8b) albo spację niełamiącą (U+00A0, bajty c2 a0). Oba są niewidoczne w każdym edytorze i oba zmieniają HMAC.

Zniekształcenia w CI i kontenerach. Sekrety przechodzące przez interpolację powłoki tracą $ na rzecz rozwinięcia albo gubią odwrotne ukośniki. Jedne systemy CI przycinają wartości, inne nie. Sekrety Kubernetesa są w manifeście w base64, a w kontenerze surowe — osobna pułapka na podwójne dekodowanie.

Naprawa polega na tym, żeby przestać patrzeć na sekret i zacząć go mierzyć. Po każdej ze stron wypisuje się długość i odcisk palca, nigdy samą wartość:

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

Obie komendy trzeba uruchomić po stronie podpisującej i weryfikującej, a potem porównać wyniki. Zgodna długość i zgodny odcisk oznaczają, że sekret nie jest problemem — wtedy wraca się do sekcji 3. Długość o jeden większa od oczekiwanej to końcowy znak nowej linii. Długość większa o dwa to cudzysłowy.

Kiedy długość się nie zgadza i trzeba zobaczyć, co dokładnie tam siedzi, można zrobić zrzut heksadecymalny w lokalnej powłoce, na sekrecie deweloperskim:

printf '%s' "$JWT_SECRET" | xxd

0a na końcu to znak nowej linii. 22 na początku i na końcu to para cudzysłowów. c2 a0 albo e2 80 8b w środku to przypadek niewidocznego znaku. Nie należy uruchamiać tego na produkcyjnym sekrecie, na maszynie, która gdziekolwiek wysyła zawartość swojego terminala.

Odpowiednik tego sprawdzenia wewnątrz działającego procesu Node albo Pythona:

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:]))

W Pythonie len(s) mniejsze niż len(s.encode("utf-8")) mówi, że w sekrecie, który miał być ASCII, siedzą znaki spoza ASCII.

5. Algorytm i typ klucza do siebie nie pasują

Nagłówek alg i podany klucz muszą należeć do tej samej rodziny. HS256 chce współdzielonego sekretu, czyli ciągu bajtów. RS256 i ES256 chcą klucza asymetrycznego, czyli PEM albo JWK. Skrzyżowanie tych przewodów daje awarie od czytelnego błędu typu aż po zwykłe invalid signature, zależnie od tego, jak pobłażliwa jest biblioteka.

Częste warianty:

  • Nagłówek mówi HS256, a strona weryfikująca podaje bibliotece publiczny klucz PEM. Niektóre biblioteki liczą HMAC z tekstu PEM i zgłaszają niezgodność podpisu.
  • Nagłówek mówi RS256, a strona weryfikująca podaje ciąg sekretu HMAC.
  • Strona weryfikująca w ogóle nie przekazuje listy algorytmów i pozwala bibliotece wywnioskować ją z alg, więc dryf konfiguracji po stronie podpisującej po cichu zmienia to, co robi weryfikator.

Ten ostatni przypadek to moment, w którym błąd konfiguracji zamienia się w błąd bezpieczeństwa, więc algorytm należy ustalić jawnie przy każdym wywołaniu weryfikującym:

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

Ustalenie algorytmu zamienia też mgliste błędy podpisu w precyzyjne. Jeśli przyjdzie token z alg: RS256, a lista dozwolonych mówi HS256, dostaje się jawny błąd algorytmu wymieniający obie wartości po imieniu.

Warto tu postawić granicę. Ta sekcja opisuje błędną konfigurację: dwa własne komponenty rozjeżdżają się ze sobą, bez udziału przeciwnika. Istnieje pokrewna awaria o tym samym kształcie, w której atakujący przepisuje alg z RS256 na HS256 i podpisuje token kluczem publicznym serwisu użytym jako sekret HMAC. To pomyłka algorytmów, czyli atak, a nie błąd, i jest opisana w najlepszych praktykach bezpieczeństwa JWT razem z resztą modelu zagrożeń. Obrona, czyli jawna lista dozwolonych, jest akurat ta sama, co stanowi niezły argument, żeby stosować ją nawet wtedy, gdy ściga się zwykłego buga.

6. Token zmienił się po drodze

Zanim padnie podejrzenie na klucze, trzeba potwierdzić, że strona weryfikująca dostała ten sam ciąg znaków, który wyprodukowała strona podpisująca. JWT jest kruchy dokładnie w taki sposób, w jaki kruche są ciągi znaków.

Przedrostek Bearer. Authorization: Bearer eyJhbGci... to wartość nagłówka, a nie token. Podział po niewłaściwym separatorze albo podział raz i zachowanie złej połowy zostawia do weryfikacji Bearer eyJhbGci... albo pusty ciąg. Trzeba go obciąć świadomie:

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

Białe znaki i łamanie wierszy. Tokeny kopiowane z terminala się zawijają. Tokeny trzymane w YAML-u bywają składane. Pojedynczy \n osadzony w trzecim segmencie daje niezgodność podpisu, a nie błąd parsowania, bo dekodery base64url często pomijają białe znaki, a porównanie ciągów już nie.

Kodowanie URL. Token, który podróżował jako parametr zapytania, może wrócić z . zamienioną na %2E albo z - i _ przetłumaczonymi przez nadgorliwy koder. Dekodować należy raz, dokładnie raz.

Ucięcie. Cookies mają limit około 4 KB każde, a tokeny RS256 z kilkoma claims rutynowo go przekraczają. Ucięty token zwykle nie przechodzi dekodowania base64, ale jeśli cięcie wypadnie na granicy 4 znaków, dostaje się token wyglądający poprawnie, tylko ze złym podpisem.

Rozstrzygną to dwie komendy. Poprawnie zbudowany JWT ma dokładnie dwie kropki:

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

A każdy znak musi należeć do alfabetu base64url, więc to nie powinno wypisać niczego:

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

Jakikolwiek wynik drugiej komendy nazywa problem po imieniu: 3d to dopełnienie =, którego nie powinno tu być, 2b albo 2f to + i / ze standardowego base64 tam, gdzie base64url oczekuje - i _, a 20 to zabłąkana spacja.

7. Awarie specyficzne dla RS256 i ES256

Algorytmy asymetryczne zamieniają problem sekretu na problem zarządzania kluczami, a tryby awarii prawie nie pokrywają się z tymi od HS256.

PKCS#1 kontra PKCS#8. To dwa formaty kontenera na ten sam klucz RSA, a wzrokowo odróżnia je jedno słowo w linii nagłówka:

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

Biblioteki różnią się tym, który z nich akceptują. Gdy któraś odrzuci format wprost, dostaje się czytelny błąd; gdy sparsuje go połowicznie, można skończyć z podpisem, który nigdy nie przechodzi weryfikacji. Zamiast z tym walczyć, lepiej przekonwertować:

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

Klucze są zamienione. Podpisywanie kluczem publicznym albo weryfikacja prywatnym. W teorii oczywiste, w praktyce łatwe, gdy oba pliki leżą w tym samym katalogu pod nazwami różniącymi się o cztery znaki. Sprawdzenie, co jest czym:

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

Klucz prywatny wypisuje rozmiar swojego modułu jako klucz prywatny; klucz publiczny wysypie się, dopóki nie dołoży się -pubin.

Dryf JWKS i kid. Przy endpoincie JWKS strona weryfikująca wybiera klucz, dopasowując kid z tokenu do zestawu kluczy. Psują się tu trzy rzeczy: strona podpisująca zrotowała klucz, a JWKS w cache weryfikatora jest nieświeży; token nie ma kid, więc weryfikator bierze pierwszy klucz z zestawu; albo dwa środowiska publikują nachodzące na siebie wartości kid. Przy takim podejrzeniu należy pobrać JWKS na świeżo i potwierdzić, że dokładnie ten kid z nagłówka tokenu w nim jest.

Kodowanie podpisu ES256. Podpisy ECDSA to para liczb całkowitych, r i s, a serializować da się je na dwa sposoby. Stosy kryptograficzne ogólnego przeznaczenia często emitują DER, czyli strukturę ASN.1 o zmiennej długości. RFC 7518 §3.4 wymaga zamiast tego formy JOSE: r i s dopełnione każde do stałej długości i sklejone, co dla P-256 daje 64 bajty. Podpis DER wrzucony do JWT nie jest po prostu zły — on ma inną długość. Token ES256, którego trzeci segment nie dekoduje się do dokładnie 64 bajtów, został więc zbudowany przez coś, co pominęło konwersję.

Żeby odseparować, czy problemem jest klucz, czy pipeline, podpisz ten sam payload niezależnie w koderze JWT i porównaj wynik z tym, co wyprodukowała usługa. Identyczne podpisy wskazują na transport albo na obsługę claims. Różne podpisy wskazują na klucz.

8. Błędy, które wyglądają na awarię podpisu, ale nią nie są

Część z nich jest naprawdę źle nazwana przez same biblioteki i właśnie dlatego ląduje w niewłaściwym zgłoszeniu.

ObjawCzym to jest naprawdęGdzie szukać
PyJWT ExpiredSignatureErrorexp jest w przeszłości. W nazwie stoi signature; przyczyną jest claim.Rozjazd zegarów między hostami albo za krótkie TTL
PyJWT ImmatureSignatureErrornbf jest w przyszłościZegar strony podpisującej wyprzedza zegar weryfikatora
Node TokenExpiredErrorexp jest w przeszłościTo samo co wyżej
Ogólne 401, bez szczegółówFramework zwinął każdą awarię weryfikacji do jednej odpowiedziWłączyć logowanie błędów na poziomie biblioteki
Działa przez kilka minut, potem przestajeWygaśnięcie tokenu, nie podpisPorównać iat i exp z zegarami obu hostów
Nie działa tylko dla jednej audienceNiezgodność aud albo issLista oczekiwanych audience po stronie weryfikatora

Nazewnictwo PyJWT to najjaskrawsza pułapka. ExpiredSignatureError zawiera słowo „signature”, ale jest podnoszony podczas walidacji claims, długo po tym, jak podpis już się poprawnie zweryfikował. Wyszukanie treści tego błędu prowadzi prosto do materiałów o diagnozowaniu podpisów, a godziny znikają w niewłaściwej części problemu.

Rozjazd zegarów daje najbardziej mylący wzorzec ze wszystkich: przerywane awarie, które nie korelują z niczym w kodzie. Jeśli zegar jednego hosta ucieknie do przodu, świeżo wydane tokeny nie przechodzą walidacji nbf albo iat zaraz po dotarciu, a awarie wędrują w miarę narastania dryfu. Pierwszy ruch to porównanie date -u na obu maszynach. Większość bibliotek przyjmuje parametr tolerancji (leeway) — to właściwa naprawa dla rozjazdu, którego nie da się usunąć, i niewłaściwa dla zegara, który jest naprawdę zepsuty.

Reguła ogólna: jeśli awaria zależy od czasu, od hosta albo od audience, to nie jest problem z podpisem. Awarie podpisu są deterministyczne. Ten sam token i ten sam klucz zawodzą tak samo, zawsze.

9. Powtarzalny scenariusz diagnostyczny

Kroki należy wykonać po kolei. Każdy albo znajduje błąd, albo odcina gałąź, a zatrzymanie się wcześnie jest tu celem samym w sobie.

  1. Zdekoduj nagłówek. Wklej token do dekodera JWT i zanotuj alg oraz kid. To przesądza o wszystkim dalej i nie wymaga klucza.
  2. Sprawdź kształt tokenu. Dokładnie dwie kropki, wyłącznie znaki base64url, bez przedrostka Bearer, bez białych znaków. Wystarczą dwie komendy z sekcji 6. Eliminuje uszkodzenie w transporcie.
  3. Ustal algorytm przy wywołaniu weryfikującym. Jeśli między alg a listą dozwolonych jest niezgodność, w miejsce ogólnika pojawi się jawny błąd wymieniający obie wartości.
  4. Zdejmij odcisk palca klucza po obu stronach. Wypisz długość w bajtach i skrócony SHA-256 po stronie podpisującej i weryfikującej, tak jak w sekcji 4. Różne wartości znaczą, że winna jest hydraulika, i do kroku 5 w ogóle się nie dochodzi.
  5. Jeśli obie strony to różne języki, rozstrzygnij interpretację bajtów. Zajrzyj do tabeli w sekcji 3, zdecyduj jawnie, czy sekret jest tekstem, czy base64, i spraw, żeby obie strony deklarowały to w kodzie, a nie przez ustawienia domyślne.
  6. Podpisz ten sam payload niezależnie. Użyj kodera JWT z kluczem uznanym za poprawny i porównaj jego trzeci segment z segmentem badanego tokenu. Zgodność znaczy, że strona podpisująca jest w porządku, a problemem jest weryfikator.
  7. Sprawdź HMAC ręcznie, na krzyż. Przepuść signing input przez generator HMAC w obu interpretacjach bajtów. Ten wariant, który zgadza się z tokenem, wskazuje stronę do zmiany.

Jeśli wszystkie siedem kroków jest za tobą, a pomoc nadal jest potrzebna: większość zgłoszeń grzęźnie dlatego, że pomija fakty przesądzające o odpowiedzi. Do zgłoszenia należy dołączyć:

  • Wartość alg z nagłówka i informację, czy jest kid
  • Język, bibliotekę i dokładną wersję po obu stronach: podpisującej i weryfikującej
  • Długość sekretu w bajtach po obu stronach oraz pierwsze 16 znaków heksadecymalnych jego SHA-256 (nigdy sam sekret)
  • Czy sekret jest przechowywany jako tekst, czy jako base64, i jak każda ze stron go konwertuje
  • Pełny signing input. Dwa pierwsze segmenty nie są wrażliwe — kto ma token, i tak je odczyta
  • Dla RS256 i ES256: linię nagłówka PEM, dosłownie

Ta lista zamienia niemożliwe do odpowiedzi „mój podpis JWT się nie zgadza” w pytanie, które ktoś naprawdę może rozstrzygnąć, zwykle w jednej odpowiedzi.

FAQ

Dlaczego ten sam sekret działa w jednym języku, a w innym nie?

Bo biblioteki nie zgadzają się co do tego, jak zamienić ciąg sekretu na bajty klucza. Node jsonwebtoken i Python PyJWT używają UTF-8; przestarzałe przeciążenie na String w jjwt używało kodeka base64 (jwtk/jjwt#204); Go i .NET zostawiają decyzję miejscu wywołania. Te same znaki, inne bajty, inny HMAC.

Czy podpis obejmuje zdekodowany payload, czy zakodowany ciąg?

Zakodowany ciąg. RFC 7515 definiuje signing input jako base64url(header) + "." + base64url(payload) w postaci dosłownego ASCII. Każda warstwa, która deserializuje payload i serializuje go z powrotem, zmienia kolejność kluczy, białe znaki albo formatowanie liczb, produkując inny ciąg, a więc inny podpis.

Mój sekret wygląda jak base64 — czy dekodować go przed podpisaniem?

Tylko jeśli druga strona robi to samo. W oderwaniu nie ma tu poprawnej odpowiedzi; wymóg brzmi: oba końce muszą się zgadzać. Sprawdź, czy ciąg używa wyłącznie znaków base64 i czy jego długość jest wielokrotnością czterech, a potem zapisz tę decyzję jawnie w kodzie po obu stronach, zamiast polegać na ustawieniach domyślnych.

Czy końcowy znak nowej linii w .env naprawdę potrafi zepsuć podpis?

Tak. HMAC konsumuje bajty, a abc\n to cztery bajty tam, gdzie abc to trzy. Powstały podpis nie ma nic wspólnego z poprawnym. Wystarczy wypisać printf '%s' "$JWT_SECRET" | wc -c na obu hostach; długość o jeden większa od oczekiwanej to prawie zawsze właśnie to.

Jak rozpoznać, czy chodzi o sekret, czy o algorytm?

Najpierw odczytaj alg z nagłówka. Jeśli zaczyna się od HS, potrzebny jest współdzielony sekret i PEM się wysypie. Jeśli zaczyna się od RS, PS albo ES, potrzebna jest para kluczy i ciąg sekretu się wysypie. Kiedy alg i typ klucza należą już do tej samej rodziny, pozostałe awarie to problemy z zawartością klucza.

Dlaczego jwt.io mówi, że podpis jest poprawny, a mój serwer go odrzuca?

Bo narzędzie online i serwer mogą interpretować sekret inaczej: jedno jako tekst UTF-8, drugie jako base64. Narzędzie waliduje względem bajtów, które samo wyprowadziło, a nie tych, które wyprowadził serwer. Poza tym produkcyjnych sekretów nigdy nie należy wklejać do serwisu trzeciej strony — do tego służy klucz deweloperski.

Czy invalid signature bywa spowodowane wygaśnięciem tokenu?

Nie. Weryfikacja podpisu biegnie przed walidacją claims, więc wygaśnięcie nigdy nie jest przyczyną. Wygaśnięcie wychodzi osobno jako TokenExpiredError w Node albo ExpiredSignatureError w PyJWT — nazwa tego drugiego myli, bo podpis zweryfikował się bez zarzutu i zawiodło wyłącznie exp.

Podsumowanie

Niezgodność podpisu prawie nigdy nie jest problemem kryptografii. HMAC-SHA256 działa. RSA działa. Zawodzi granica, na której ciąg znaków staje się bajtami: kodek base64 po jednej stronie i UTF-8 po drugiej, znak nowej linii, który zachował loader konfiguracji, payload, który gateway usłużnie zserializował na nowo. Każda przyczyna z tego przewodnika to spór o bajty.

Trzeba więc uczynić bajty jawnymi i przestać polegać na ustawieniach domyślnych. W dokumentacji zespołu powinno stać, czy współdzielony sekret trzymany jest jako surowy tekst, czy jako base64, a każda usługa powinna konwertować go zadeklarowanym sposobem, zamiast dziedziczyć to, co założyła jej biblioteka. W systemach rozpiętych na wiele języków sekrety najlepiej trzymać w hex albo base64 i dekodować je jawnie w każdym miejscu wywołania: jedna linia na usługę i dwuznaczność znika. Potem dołóż do health checku odcisk palca długości w bajtach z sekcji 4, żeby następna niezgodność pokazała się jako ostrzeżenie przy starcie, a nie jako produkcyjne 401.

Tagi: jwt authentication debugging hmac api-security

Powiązane artykuły

Zobacz wszystkie artykuły