Wat er werkelijk in een JSON Web Token zit

Binnenin een JWT, met de header, payload en handtekening uitgelegd, de standaardclaims, en waarom een token Base64url-decoderen niet hetzelfde is als hem verifiëren.

Een JSON Web Token (JWT) bestaat uit drie stukken tekst die door punten aan elkaar zitten. Elk stuk is Base64url-gecodeerd, en de eerste twee zijn gewoon JSON. Iedereen die de token heeft, kan lezen wat erin staat. Niets in een gewone JWT is versleuteld.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9      <- header
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbGV4In0   <- payload
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFW     <- signature

Met punten aaneengeregen is dat de token die je in een verzoek plakt. Decodeer de eerste twee delen en je krijgt gewone JSON:

{ "alg": "HS256", "typ": "JWT" }
{ "sub": "12345", "name": "Alex" }

Een echte payload draagt meestal ook tijdstempels en een doelgroep:

{
  "iss": "https://auth.example.com",
  "sub": "12345",
  "aud": "api.example.com",
  "iat": 1756000000,
  "exp": 1756003600,
  "roles": ["editor"]
}

De header

De header beschrijft hoe de token ondertekend is. Twee velden tellen het zwaarst:

  • alg noemt het ondertekeningsalgoritme, bijvoorbeeld HS256 (HMAC met SHA-256, met een gedeeld geheim) of RS256 (RSA-handtekening met SHA-256, ondertekend met een privésleutel en gecontroleerd met een publieke).
  • kid, het sleutel-ID, is optioneel en vertelt de ontvanger welke van meerdere sleutels gebruikt is, zodat sleutels vervangen kunnen worden zonder bestaande tokens te breken.

typ is meestal gewoon JWT en heeft geen betekenis voor de beveiliging.

De payload en zijn claims

De payload is een JSON-object met claims, wat simpelweg het woord van de specificatie is voor uitspraken over het onderwerp van de token. Sommige claimnamen zijn door de standaard geregistreerd, en ze zijn allemaal kort:

ClaimNaamWat hij bevat
issIssuerWie de token heeft aangemaakt, meestal een URL of dienstnaam
subSubjectOver wie de token gaat, meestal een stabiel gebruikers-ID
audAudienceVoor wie de token bedoeld is, zodat een dienst tokens voor een andere dienst kan weigeren
expExpiration timeHet moment waarna de token geweigerd moet worden
nbfNot beforeHet moment waarvóór de token geweigerd moet worden
iatIssued atWanneer de token is aangemaakt
jtiJWT IDEen unieke identifier, handig om afzonderlijke tokens te volgen of te blokkeren

exp, nbf en iat zijn allemaal NumericDate-waarden, oftewel seconden sinds 1 januari 1970 UTC. Het zijn seconden, geen milliseconden. Als een tijdstempel decodeert naar een datum in het jaar 57.000, lees je milliseconden en moet je door duizend delen.

Al het overige in de payload is wat de uitgever erin heeft gezet: rollen, rechten, een e-mailadres, een tenant-ID. Omdat een eigen claimnaam ooit met een geregistreerde zou kunnen botsen, geven uitgevers hun eigen claims vaak een namespace met een voorvoegsel in de vorm van een URL.

De handtekening

De handtekening wordt berekend over de exacte tekst van de eerste twee delen aaneengeregen met een punt, met het algoritme uit de header en een sleutel. Verander één teken van de header of payload en de handtekening klopt niet meer.

Bij HS256 ondertekent en verifieert hetzelfde geheim, dus wie een token kan controleren kan er ook een aanmaken. Bij RS256 of ES256 ondertekent een privésleutel en verifieert een publieke sleutel, en dat is wat een identiteitsprovider in staat stelt tokens uit te delen die veel losse diensten kunnen valideren zonder iets geheims in handen te hebben.

Base64url is geen encryptie

Base64url is hetzelfde idee als Base64, met twee verschillen: het gebruikt - en _ in plaats van + en / zodat het resultaat veilig is in URL's, en de aanvulling met = aan het eind wordt meestal weggelaten. Het is een codering, een omkeerbare manier om bytes als tekst te schrijven. Het biedt geen enkele vertrouwelijkheid.

Zet dus nooit iets gevoeligs in een JWT-payload: geen wachtwoorden, geen kaartnummers, geen interne aantekeningen over de gebruiker. Ga ervan uit dat degene die de token heeft, en iedereen die hem uit een logbestand of browseropslag leest, elk veld kan zien.

Er bestaat een apart formaat, JWE, dat de payload wél versleutelt, en dat heeft vijf delen in plaats van drie. Heeft je token drie delen, dan is hij ondertekend en leesbaar.

Decoderen is niet verifiëren

Dit is het punt dat ertoe doet. Een decoder laat je zien wat een token zegt. Hij vertelt je niet of de token echt is. Verificatie is een aparte stap en heeft de sleutel nodig.

Om een token echt te vertrouwen, moet een server:

  1. De handtekening controleren tegen de juiste sleutel.
  2. Het algoritme afdwingen dat hij verwacht, in plaats van op het veld alg in de token te vertrouwen. Twee klassieke aanvallen komen voort uit het overslaan hiervan: alg op none zetten en de handtekening weglaten, en de publieke RSA-sleutel van een dienst pakken en die als HMAC-geheim gebruiken, zodat een verifier voor RS256 verleid wordt om HS256 uit te voeren.
  3. exp controleren en, als hij aanwezig is, nbf, met een kleine marge voor klokafwijking.
  4. Controleren of iss en aud overeenkomen met wat deze dienst verwacht.

Pas dan betekenen de claims iets. Tot dat moment is de payload een ongeverifieerde string die over het netwerk is aangekomen.

Praktische opmerkingen

  • Tokens worden meestal in een HTTP-header verstuurd als Authorization: Bearer <token>, dus houd de payload klein. Elke claim die je toevoegt gaat bij elk verzoek mee.
  • Een ondertekende token blijft geldig tot hij verloopt. Er is geen ingebouwde manier om er een in te trekken, en daarom hebben access tokens meestal een korte levensduur, vaak minuten, met een aparte refresh token om nieuwe te halen.
  • Ziet een token er misvormd uit, tel dan eerst de punten. Twee punten en drie delen is een normale ondertekende JWT. Een afgekapte token die uit een terminal geplakt is, is een veelvoorkomende oorzaak van "invalid signature".
  • Een token decoderen om tijdens het debuggen zijn vervaltijd, zijn onderwerp of zijn rollen te lezen is volstrekt veilig en vereist geen geheim. Daar is de payload precies voor.