Skip to content
Terug naar blog
Beveiliging

JWT invalid signature: alle oorzaken en hoe je ze oplost

Een ongeldige JWT-handtekening komt zelden door crypto: keybytes verschillen per taal, .env voegt een regeleinde toe. Zo vind je de jouwe. Gratis decoder.

14 min leestijd

JWT invalid signature: alle oorzaken en hoe je ze oplost

Een JWT die invalid signature meldt, betekent precies één ding: de handtekening die jouw verifier berekende, is niet gelijk aan de handtekening die in het token zit. Meer zegt die melding niet. Niet dat het token verlopen is, niet dat je JWT-library kapot is. De kant die ondertekent en de kant die controleert voeren niet hetzelfde in: er gaan andere bytes de HMAC in, of er gaat een andere publieke sleutel de verify-aanroep in.

Meestal zit de dader in het sleutelmateriaal en niet in het token. Bepaal hiermee waar je begint:

Welk algoritme staat er in de header?
├─ HS256 / HS384 / HS512  → vrijwel altijd een probleem met het secret
│    ├─ ondertekenaar en verifier in verschillende talen? → sectie 3
│    └─ zelfde taal, lokaal goed, in productie stuk?      → sectie 4
└─ RS256 / ES256 / PS256  → vrijwel altijd het sleutelformaat of de verkeerde sleutel
     └─ → sectie 7

Token door een gateway, proxy of kopieer-plakactie gegaan? → sectie 6
Fout verschijnt pas na een paar uur of op één host?        → sectie 8

De snelste eerste zet: plak het token in de JWT decoder en lees het veld alg. Zodra je dat weet, vallen de meeste takken hierboven weg.

1. Wat “invalid signature” precies zegt

Verschillende libraries drukken voor dezelfde fout verschillende strings af. Zoek de jouwe op:

  • 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 drie slaan op hetzelfde moment in hetzelfde stuk code toe. De library neemt de eerste twee segmenten van je token, berekent de handtekening opnieuw met de sleutel die jij hebt meegegeven, en vergelijkt het resultaat byte voor byte met het derde segment. Niet gelijk: gooien.

Die vergelijking is exact en zegt niets over hoe ver de twee waarden uit elkaar liggen. Eén byte verschil in het secret en een volstrekt verkeerde sleutel leveren identieke foutmeldingen op. De fout nauwkeuriger lezen helpt dus niet; de invoerruimte verkleinen wel.

Let ook op wat er nog niet gebeurd is op het moment dat deze fout afgaat. Claim-validatie komt pas ná de verificatie van de handtekening, dus naar exp, nbf, aud en iss is nog geen enkele keer gekeken. Krijg je een JWT signature verification failed, dan is de inhoud van het token niet relevant voor de diagnose. Lezen kun je die inhoud nog steeds, want een JWT is geëncodeerd en niet versleuteld. Voor het decoderen van header en payload heb je helemaal geen sleutel nodig; zie een JWT decoderen voor de uitleg segment voor segment.

Twee velden in de header bepalen waar je hierna heen gaat: alg vertelt je of je op jacht bent naar een gedeeld secret of naar een sleutelpaar, en kid vertelt je welke sleutel de ondertekenende kant dacht te gebruiken.

2. De handtekening dekt de geëncodeerde string, niet je object

De meeste ontwikkelaars hebben dit precies omgekeerd in hun hoofd zitten.

RFC 7515, de specificatie van JSON Web Signature, definieert de JWS Signing Input als de ASCII-string:

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

De HMAC gaat over die string. Niet over je claims-object en niet over wat jouw taal verder als gestructureerde data ziet. Dit hele artikel gebruikt één signing input, afkomstig uit de standaard voorbeeldpayload:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Het gevolg is hard: elke laag die de payload decodeert en opnieuw encodeert, sloopt de handtekening. JSON-serialisatie is niet canoniek. De volgorde van de keys verandert zodra de payload er in de meeste talen doorheen gaat. Witruimte verschijnt of verdwijnt. De ene serializer escapet niet-ASCII-tekens als \uXXXX, de andere schrijft ze letterlijk uit. Getallen krijgen een andere notatie, waardoor 1516239022 terug kan komen als 1516239022.0. Elk van die dingen levert een andere base64url-string op, dus een andere signing input, dus een andere handtekening.

Aanleidingen die we in de praktijk zijn tegengekomen:

  • Een API-gateway die de JWT uitleest om er een tenant-ID aan toe te voegen en het token opnieuw uitgeeft.
  • Een logging- of tracing-middleware die headers “normaliseert” en de waarde van Authorization herschrijft.
  • Een ontwikkelaar die een token liet formatteren om het te kunnen lezen en daarna die geformatteerde versie terugplakte.

Als een component tussen je ondertekenaar en je verifier het token kan herschrijven, is die component verdachte nummer één. Tokens zijn onderweg ondoorzichtige strings; de enige veilige bewerkingen zijn opslaan, kopiëren en vergelijken.

3. Zelfde secret, andere bytes

Hier komt elk bugrapport van het type “het secret is letterlijk identiek, ik heb ze naast elkaar gelegd” vandaan.

HMAC eet geen string. HMAC eet bytes. Je configuratiebestand, je secrets manager en je omgevingsvariabelen slaan allemaal strings op. Iets moet het een in het ander omzetten, en die omzetting verschilt per JWT-library. Twee services kunnen teken voor teken identieke secrets bevatten en toch verschillende handtekeningen berekenen.

Het bewijs, lokaal berekend tegen de signing input uit sectie 2. De secret-string hieronder is 36 tekens lang:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Byte-interpretatieBytesWat de sleutel werkelijk isResulterende HS256-handtekening
Als UTF-8-tekst behandeld36de 36 zichtbare tekens zelftUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Eerst base64 gedecodeerd27secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Dezelfde secret-string, hetzelfde algoritme, dezelfde payload. Twee handtekeningen die niets met elkaar gemeen hebben. De kant die het “fout” deed, meldt invalid signature. Je kunt de configuratiebestanden blijven vergelijken zonder iets te vinden, want ze zijn identiek.

Het volledige token voor de UTF-8-lezing, mocht je dit willen reproduceren:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Plak het samen met het secret hierboven in de JWT decoder en het verifieert. Decodeer het secret eerst als base64 en het verifieert niet.

Hoe elke library een string in keybytes omzet

Hou je aan wat gedocumenteerd is. In de tabel hieronder telt de laatste kolom zwaarder dan de eerste.

Runtime / libraryGedrag bij string naar bytesWie beslist
Node jsonwebtokenUTF-8-bytes van de stringde library
Python PyJWTUTF-8-bytes van de stringde library
Java jjwt, oude String-overloadbase64-codec van het platform, volgens jwtk/jjwt#204de library
Go golang-jwtneemt rechtstreeks []bytejij, op de aanroepplek
.NETneemt rechtstreeks byte[]jij, op de aanroepplek

De Java-regel is de historische bron van de pijn tussen stacks, en hij wordt zelden precies naverteld. In oude jjwt-versies haalden signWith(SignatureAlgorithm, String) en zijn verwanten de String door een base64-codec in plaats van de ruwe bytes te nemen, terwijl de byte[]-overloads de bytes namen zoals ze waren. Een Node-service en een Java-service die één secret deelden, waren het daardoor oneens. Die String-API is sinds jjwt 0.10 deprecated, en de moderne vorm is expliciet:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Dit is dus niet “zo werkt Java met JWT’s”. Het is één legacy-overload van één library, en moderne jjwt-code die een byte[] doorgeeft, kent geen enkele dubbelzinnigheid. Het spiegelbeeldige rapport aan de Node-kant is auth0/node-jsonwebtoken#208, waarin tokens die in Java waren ondertekend niet in Node wilden verifiëren. Voor PHP bestaan er vergelijkbare meldingen tegen firebase/php-jwt (zie firebase/php-jwt#153). De byte-afhandeling van die library hebben we zelf niet geverifieerd, dus behandel het als een spoor en niet als een diagnose.

Go en .NET horen in een andere categorie. Geen van beide libraries beslist voor jou; ze geven je allebei de parameter []byte / byte[] en doen verder een stap terug. []byte(secret) en Encoding.UTF8.GetBytes(secret) leveren UTF-8 op, terwijl Convert.FromBase64String(secret) gedecodeerde bytes oplevert. De bug zit, als hij optreedt, in je eigen aanroep. Dat is goed nieuws, want dan is hij zichtbaar in je eigen diff.

Is mijn JWT-secret base64 of UTF-8?

Er is geen vlag in het token die je dat vertelt. Je moet redeneren over de string zelf:

  1. Gebruikt hij alleen A–Z a–z 0–9 + / = (of - en _)? Zo ja, dan kan het base64 zijn. Een secret met een spatie, een ! of een # erin kan het niet zijn.
  2. Is de lengte een veelvoud van 4, of eindigt hij op =-padding? Allebei zijn sterke aanwijzingen dat iets het onderweg base64 heeft geëncodeerd.
  3. Levert base64-decodering er zinnige bytes op? Haal hem door de Base64-decoder. Leesbare ASCII of precies 32 willekeurig ogende bytes wijzen op base64. Mojibake wijst erop dat de string nooit geëncodeerd is geweest.

Een secret als c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm slaagt voor alle drie de tests, en precies daarom is het gevaarlijk: het is dubbelzinnig en beide lezingen zijn plausibel. Secrets met een - of _ erin zijn nog vervelender dubbelzinnig, want ze zijn geldig base64url maar ongeldig standaard-base64.

Kom je er met redeneren niet uit, bereken dan allebei. Haal de signing input twee keer door de HMAC-generator met HMAC-SHA256: één keer met het secret als tekst, één keer met de gedecodeerde bytes. Vergelijk beide uitkomsten met het derde segment van het token. Eén van de twee komt overeen, en daarmee weet je welke kant van je systeem gelijk heeft.

Tekens zijn geen bytes

De verwante valkuil is tekens tellen terwijl de eis in bytes staat. RFC 7518 §3.2 legt de ondergrens voor de sleutel bij HMAC-SHA vast in bits, niet in tekens, en geëncodeerde tekst zet uit:

Hoe je het opschrijftEntropieEquivalent in bytesVoor HS256 (vereist ≥256 bit)
32 hex-tekens128 bit16 bytes❌ onder de ondergrens
32 base64-tekens192 bit24 bytes❌ onder de ondergrens
32 willekeurige bytes256 bit32 bytes✅ voldoet (64 tekens in hex, 44 in base64 met padding)

Een “secret van 32 tekens” kan afhankelijk van het alfabet van alles zijn tussen 128 en 256 bit. Dit staat los van het probleem met de byte-interpretatie hierboven, maar het bijt dezelfde mensen, want een team dat in tekens meet, is meestal een team dat nooit naar de bytes heeft gekeken. Voor de keuzeregels zelf, zoals lengte en encoding, ga je naar de generator voor JWT-secrets.

4. Het secret zelf is vervuild geraakt

Je twee services zijn het eens over de byte-interpretatie. De handtekening klopt nog steeds niet. Controleer nu of het secret dat elke kant heeft ingeladen, ook het secret is dat jij denkt te hebben opgeschreven. De leidingen waarlangs je omgevingsvariabelen lopen, zijn opmerkelijk goed in het toevoegen van een byte.

Een regeleinde aan het eind van .env. Sommige loaders lezen JWT_SECRET=abc gevolgd door een regelovergang in als abc\n. Eén byte extra, en HMAC produceert een volstrekt ongerelateerde uitvoer. Bijna goed bestaat niet.

Aanhalingstekens die als data worden gelezen. JWT_SECRET="abc" betekent abc voor de ene loader en "abc" voor de andere, vooral wanneer een shell het bestand inleest in plaats van een library. env_file van Docker Compose en een .env-parser kunnen het over hetzelfde bestand oneens zijn.

Onzichtbare tekens door kopiëren en plakken. Een secret uit Slack of een PDF kopiëren kan een zero-width space (U+200B, bytes e2 80 8b) of een non-breaking space (U+00A0, bytes c2 a0) meeslepen. Beide zijn in elke editor onzichtbaar en beide veranderen de HMAC.

Verminking door CI en containers. Secrets die door shell-interpolatie gaan, krijgen hun $ uitgeklapt of hun backslashes opgegeten. Sommige CI-systemen trimmen waarden, andere niet. Kubernetes-secrets staan als base64 in het manifest en ruw in de container, op zichzelf al een valkuil van dubbele decodering.

Meet het secret in plaats van ernaar te kijken. Druk aan beide kanten de lengte en een vingerafdruk af, nooit de waarde zelf:

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

Voer beide commando’s uit op de ondertekenende en de verifiërende kant en vergelijk de uitkomsten. Gelijke lengte en gelijke vingerafdruk betekenen dat het secret je probleem niet is, dus ga terug naar sectie 3. Een lengte die één groter is dan je verwacht, is het regeleinde. Twee groter zijn de aanhalingstekens.

Klopt de lengte niet en wil je precies zien wat erin zit, maak er dan een hex-dump van in een lokale shell, tegen een ontwikkelsecret:

printf '%s' "$JWT_SECRET" | xxd

Een 0a aan het eind is een regeleinde. Een 22 vooraan en achteraan is een paar aanhalingstekens. c2 a0 of e2 80 8b in het midden zijn de onzichtbare tekens. Voer dit niet uit tegen een productiesecret op een machine die haar terminaluitvoer doorstuurt of wegschrijft.

De equivalente controle binnen een draaiend Node- of Python-proces:

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 betekent een len(s) die lager uitvalt dan len(s.encode("utf-8")) dat er niet-ASCII-tekens zitten in een secret dat ASCII had moeten zijn.

5. Algoritme en sleuteltype passen niet bij elkaar

De header alg en de sleutel die je meegeeft, moeten tot dezelfde familie horen. HS256 wil een gedeeld secret, oftewel een byte-string. RS256 en ES256 willen een asymmetrische sleutel, oftewel een PEM of JWK. Kruis die draden en je krijgt van alles terug: een heldere typefout, of een kaal invalid signature, afhankelijk van hoe vergevingsgezind de library is.

Veelvoorkomende varianten hiervan:

  • De header zegt HS256 en de verifier geeft de library een publieke sleutel in PEM-vorm mee. Sommige libraries berekenen dan de HMAC over de PEM-tekst en melden een handtekening die niet overeenkomt.
  • De header zegt RS256 en de verifier geeft de secret-string voor HMAC mee.
  • De verifier geeft helemaal geen lijst met algoritmen mee en laat de library het afleiden uit alg, waardoor een configuratieafwijking aan de ondertekenende kant stilletjes verandert wat de verifier doet.

Die laatste is waar een configuratiefout verandert in een beveiligingsfout, dus pin het algoritme expliciet vast bij elke verify-aanroep:

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

Vastpinnen zet vage handtekeningfouten bovendien om in precieze. Komt er een token binnen met alg: RS256 terwijl je allowlist HS256 zegt, dan krijg je een expliciete algoritmefout die beide waarden noemt.

Dit alles is verkeerde configuratie: twee van je eigen componenten die het oneens zijn, zonder tegenstander in het spel. Er bestaat een verwante fout met dezelfde vorm, waarbij een aanvaller alg herschrijft van RS256 naar HS256 en met jouw publieke sleutel als HMAC-secret ondertekent. Dat heet algoritmeverwarring. Het is een aanval en geen bug, en het staat samen met de rest van het dreigingsmodel in de aanbevolen aanpak voor JWT-beveiliging. De verdediging is toevallig dezelfde, een expliciete allowlist, en dat is een goed argument om hem ook toe te passen als je alleen maar een bug najaagt.

6. Het token is onderweg veranderd

Voor je de sleutels de schuld geeft: bevestig dat de verifier dezelfde string ontving als de ondertekenende kant produceerde. Een JWT is onderweg niets meer dan een string, en dus even kwetsbaar.

Het Bearer-voorvoegsel. Authorization: Bearer eyJhbGci... is een headerwaarde, geen token. Splits je op het verkeerde teken, of hou je na het splitsen de verkeerde helft, dan verifieer je Bearer eyJhbGci... of een lege string. Haal het er bewust af:

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

Witruimte en regeleindes. Tokens die uit een terminal gekopieerd zijn, breken af; YAML vouwt lange regels om. Eén enkele \n in het derde segment levert een handtekening op die niet klopt, en geen fout bij het inlezen, want base64url-decoders slaan witruimte vaak over terwijl de stringvergelijking dat niet doet.

URL-encoding. Een token dat als queryparameter is meegereisd, kan terugkomen met . als %2E, of met - en _ vertaald door een al te ijverige encoder. Decodeer één keer, precies één keer.

Afkapping. Een cookie loopt rond 4 KB tegen zijn plafond aan, en RS256-tokens met een paar claims gaan daar routineus overheen. Een afgekapt token faalt meestal al bij het base64-decoderen, maar als het op een grens van vier tekens is afgeknipt, krijg je in plaats daarvan een token dat er geldig uitziet met een verkeerde handtekening.

Twee commando’s beslechten dit. Een welgevormde JWT heeft precies twee punten:

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

En elk teken moet in het base64url-alfabet zitten, dus dit hoort helemaal niets af te drukken:

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

Elke uitvoer van het tweede commando benoemt je probleem: 3d is =-padding die er niet hoort te staan, 2b of 2f zijn de + en / van standaard-base64 waar base64url - en _ verwacht, en 20 is een verdwaalde spatie.

7. Specifieke fouten bij RS256 en ES256

Asymmetrische algoritmen ruilen het probleem met het secret in voor een probleem met sleutelbeheer.

PKCS#1 versus PKCS#8. Dit zijn twee containerformaten voor dezelfde RSA-sleutel, en je ziet het verschil aan één woord in de headerregel:

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

Libraries verschillen in wat ze accepteren. Weigert er een het formaat botweg, dan krijg je een heldere fout; leest er een het half in, dan kun je een handtekening krijgen die nooit verifieert. Zet het om in plaats van ertegen te vechten:

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

De sleutels zijn verwisseld. Ondertekenen met de publieke sleutel, of verifiëren met de private. In principe overduidelijk, in de praktijk zo gebeurd wanneer beide bestanden in dezelfde map staan onder namen die vier tekens schelen. Controleer welke welke is:

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

Een private sleutel drukt zijn modulusgrootte af als private sleutel; een publieke sleutel geeft een fout tenzij je -pubin toevoegt.

JWKS en kid-drift. Met een JWKS-endpoint kiest de verifier een sleutel door de kid uit het token te vergelijken met de sleutelset. Er gaat hier van alles mis: de ondertekenende kant heeft geroteerd terwijl de JWKS in de cache van de verifier verouderd is; het token heeft geen kid en de verifier pakt de eerste sleutel uit de set; of twee omgevingen publiceren overlappende kid-waarden. Vermoed je dit, haal de JWKS dan vers op en bevestig dat exact de kid uit de tokenheader erin staat.

De encoding van de ES256-handtekening. ECDSA-handtekeningen zijn een paar gehele getallen, r en s, en er zijn twee manieren om ze te serialiseren. Algemene cryptostacks zenden vaak DER uit, een ASN.1-structuur met variabele lengte. RFC 7518 §3.4 eist in plaats daarvan de JOSE-vorm: r en s elk aangevuld tot een vaste lengte en aan elkaar geplakt, wat voor P-256 neerkomt op 64 bytes. Een DER-handtekening die in een JWT belandt, heeft bovendien een andere lengte. Een ES256-token waarvan het derde segment niet naar precies 64 bytes decodeert, is dus gebouwd door iets dat die omzetting heeft overgeslagen.

Wil je uitzoeken of het probleem in je sleutel of in je pijplijn zit, onderteken dezelfde payload dan onafhankelijk in de JWT-encoder en vergelijk die uitvoer met wat je service produceerde. Identieke handtekeningen wijzen op transport of op de omgang met claims. Verschillende handtekeningen wijzen op de sleutel.

8. Fouten die op handtekeningfouten lijken maar het niet zijn

Sommige hiervan benoemen de libraries zelf verkeerd, en zo belanden ze in het verkeerde bugrapport.

SymptoomWat het werkelijk isWaar je moet kijken
PyJWT ExpiredSignatureErrorexp ligt in het verleden. De naam zegt signature; de oorzaak is een claim.Klokverschil tussen hosts, of een te korte TTL
PyJWT ImmatureSignatureErrornbf ligt in de toekomstDe klok van de ondertekenaar loopt voor op die van de verifier
Node TokenExpiredErrorexp ligt in het verledenZelfde als hierboven
Algemene 401, geen detailsHet framework heeft elke verificatiefout tot één antwoord platgeslagenZet foutlogging op libraryniveau aan
Werkt een paar minuten, faalt daarnaHet token verloopt, geen handtekeningprobleemVergelijk iat en exp met de klokken van beide hosts
Faalt alleen voor één audienceaud of iss komt niet overeenDe lijst met verwachte audiences bij de verifier

De naamgeving van PyJWT is de opvallendste valkuil. ExpiredSignatureError bevat het woord “signature” maar wordt tijdens claim-validatie gegooid, lang nadat de handtekening al met succes geverifieerd is. Zoeken op die foutstring leidt je regelrecht naar materiaal over handtekeningproblemen, en daar verdwijnen uren in.

Klokverschil levert het verwarrendste patroon van allemaal op: sporadische fouten die met niets in je code correleren. Loopt de klok van één host voor, dan falen vers uitgegeven tokens bij aankomst op nbf of iat, en de fouten dwalen mee terwijl het verschil groeit. Vergelijk eerst date -u op beide machines. De meeste libraries accepteren een leeway-parameter: de juiste oplossing voor een verschil dat je niet kunt wegwerken, en de verkeerde voor een klok die echt kapot is.

De algemene regel: is de fout tijdsafhankelijk, hostafhankelijk of audience-afhankelijk, dan is het geen handtekeningprobleem. Handtekeningfouten zijn deterministisch. Hetzelfde token en dezelfde sleutel falen altijd op dezelfde manier.

9. Een herhaalbare werkwijze om de fout op te sporen

Voer deze stappen in volgorde uit. Elke stap vindt de bug of schrapt een tak, en vroeg stoppen is precies de bedoeling.

  1. Decodeer de header. Plak het token in de JWT decoder en noteer alg en kid. Dit bepaalt alles wat erna komt en er is geen sleutel voor nodig.
  2. Controleer de vorm van het token. Precies twee punten, alleen base64url-tekens, geen Bearer-voorvoegsel, geen witruimte. Gebruik de twee commando’s uit sectie 6. Dit sluit corruptie tijdens transport uit.
  3. Pin het algoritme vast bij de verify-aanroep. Bestaat er een verschil tussen alg en je allowlist, dan krijg je nu een expliciete fout die beide noemt in plaats van een algemene.
  4. Maak aan beide kanten een vingerafdruk van de sleutel. Druk de bytelengte en een afgekapte SHA-256 af op de ondertekenende en de verifiërende kant, zoals in sectie 4. Verschillende waarden betekenen dat het leidingwerk de schuldige is en dat je stap 5 nooit bereikt.
  5. Zijn de twee kanten verschillende talen, los dan de byte-interpretatie op. Raadpleeg de tabel in sectie 3, beslis expliciet of het secret tekst of base64 is, en laat beide kanten dat in code zeggen in plaats van het aan de standaardwaarden over te laten.
  6. Onderteken dezelfde payload onafhankelijk opnieuw. Gebruik de JWT-encoder met de sleutel die je juist acht en vergelijk het derde segment met dat van jouw token. Komt het overeen, dan is je ondertekenende kant in orde en zit het probleem bij de verifier.
  7. Controleer de HMAC met de hand. Haal de signing input door de HMAC-generator met beide byte-interpretaties. Welke van de twee overeenkomt met het token, vertelt je welke kant je moet aanpassen.

Kom je alle zeven stappen door en heb je nog steeds hulp nodig, zorg dan dat je vraag de feiten bevat die het antwoord bepalen. Daar strandt het gros van de bugrapporten op. Neem op:

  • De waarde van alg uit de header, en of er een kid aanwezig is
  • Taal, library en exacte versie aan beide kanten, ondertekenend én verifiërend
  • De lengte in bytes van het secret aan beide kanten, plus de eerste 16 hex-tekens van de SHA-256 ervan (nooit het secret zelf)
  • Of het secret als tekst of als base64 is opgeslagen, en hoe elke kant het omzet
  • De volledige signing input. De eerste twee segmenten zijn niet gevoelig; wie het token heeft, kan ze toch al lezen
  • Voor RS256 en ES256: de headerregel van de PEM, letterlijk

Die lijst maakt van een onbeantwoordbare “mijn JWT-handtekening komt niet overeen” een vraag die iemand daadwerkelijk kan oplossen, meestal in één antwoord.

FAQ

Waarom werkt hetzelfde secret in de ene taal wel en in de andere niet?

Omdat libraries het oneens zijn over hoe je een secret-string in keybytes omzet. Node jsonwebtoken en Python PyJWT gebruiken UTF-8; de oude String-overload van jjwt gebruikte een base64-codec (jwtk/jjwt#204); Go en .NET laten de beslissing aan jouw aanroep over. Dezelfde tekens, andere bytes, andere HMAC.

Dekt de handtekening de gedecodeerde payload of de geëncodeerde string?

De geëncodeerde string. RFC 7515 definieert de signing input als base64url(header) + "." + base64url(payload), letterlijk als ASCII. Elke laag die de payload deserialiseert en opnieuw serialiseert, verandert de volgorde van de keys, de witruimte of de notatie van getallen, waardoor er een andere string en dus een andere handtekening ontstaat.

Mijn secret ziet eruit als base64 — moet ik het decoderen voor het ondertekenen?

Alleen als de andere kant dat ook doet. Los van elkaar bestaat er geen goed antwoord; de eis is dat beide kanten het eens zijn. Kijk of de string alleen base64-tekens gebruikt en een lengte heeft die een veelvoud van vier is, en leg de keuze daarna aan beide kanten expliciet in code vast in plaats van op standaardwaarden te vertrouwen.

Kan een regeleinde aan het eind van .env de handtekening echt slopen?

Ja. HMAC eet bytes, en abc\n is vier bytes waar abc er drie is. De handtekening die eruit komt, deelt niets met de juiste. Druk printf '%s' "$JWT_SECRET" | wc -c af op beide hosts; een lengte die één groter is dan verwacht, is vrijwel altijd dit.

Hoe weet ik of het aan het secret ligt of aan het algoritme?

Lees eerst alg uit de header. Begint die met HS, dan heb je een gedeeld secret nodig en zal een PEM falen. Begint die met RS, PS of ES, dan heb je een sleutelpaar nodig en zal een secret-string falen. Zodra alg en het sleuteltype tot dezelfde familie horen, zijn de resterende fouten problemen met de inhoud van de sleutel.

Waarom zegt jwt.io dat de handtekening geldig is terwijl mijn server hem weigert?

Omdat de online tool en je server het secret verschillend kunnen opvatten: de een als UTF-8-tekst, de ander als base64. De tool valideert tegen de bytes die hij zelf afleidde, niet tegen de bytes die je server afleidde. En plak nooit productiesecrets in een site van derden; gebruik een ontwikkelsleutel.

Kan een verlopen token ooit invalid signature veroorzaken?

Nee. Verificatie van de handtekening komt vóór claim-validatie, dus verlopen zijn is nooit de oorzaak. Verlopen komt apart naar boven als TokenExpiredError in Node of ExpiredSignatureError in PyJWT. Die laatste naam is misleidend: de handtekening verifieerde prima en alleen exp faalde.

Conclusie

Handtekeningen die niet overeenkomen, zijn vrijwel nooit een cryptografieprobleem. HMAC-SHA256 werkt. RSA werkt. Wat faalt is de grens waar een string bytes wordt: een base64-codec aan de ene kant en UTF-8 aan de andere, een regeleinde dat een configuratielader heeft bewaard, een payload die een gateway behulpzaam opnieuw heeft geserialiseerd. Elke oorzaak in deze gids is een meningsverschil over bytes.

Maak de bytes dus expliciet en stop met leunen op standaardwaarden. Leg in de documentatie van je team vast of het gedeelde secret als ruwe tekst of als base64 is opgeslagen, en laat elke service het op die vastgelegde manier omzetten in plaats van over te nemen wat de library toevallig aannam. Voor systemen die meerdere talen omspannen: sla secrets op in hex of base64 en decodeer ze expliciet op elke aanroepplek. Dat is één regel per service, en de dubbelzinnigheid is weg. Voeg daarna de vingerafdruk van de bytelengte uit sectie 4 toe aan je health check, zodat de volgende mismatch als waarschuwing bij het opstarten verschijnt in plaats van als een 401 in productie.

Tags: jwt authentication debugging hmac api-security

Gerelateerde artikelen

Alle artikelen bekijken