P
imvdmolen.nl
Blog

Hoe ik API-integraties beveilig met JSON Web Tokens en Laravel

Gisteren kreeg ik weer een vraag van een klant over API-beveiliging voor hun nieuwe mobiele app. Ze wilden weten waarom ik JWT's voorstelde in plaats van gewone sessies. Het antwoord is simpel: schaalbaarheid. Sessies vereisen server-side opslag en dat wordt een probleem zodra je meerdere servers hebt draaien. JWT's dragen alle benodigde informatie bij zich en maken het mogelijk om stateless te werken, wat perfect uitkomt voor API's die door verschillende clients worden aangesproken.

Vorig jaar bouwde ik een systeem waarbij een Laravel backend data moest leveren aan zowel een React webapp als een Flutter mobiele app. Sessies vielen meteen af omdat mobiele apps niet werken met cookies. Een simpele API-key was ook onvoldoende omdat we gebruikersspecifieke rechten nodig hadden. JWT gaf ons precies wat we zochten: authenticatie en autorisatie in één mechanisme. Na implementatie konden beide clients probleemloos communiceren met dezelfde endpoints, elk met hun eigen gebruikerscontext.

De structuur van een JWT

Technisch gezien bestaat een JWT uit drie delen die met punten van elkaar gescheiden zijn. De header vertelt welk algoritme gebruikt wordt voor de handtekening. De payload bevat de daadwerkelijke data zoals gebruikers-ID, naam of rollen. De signature zorgt ervoor dat niemand het token kan aanpassen zonder dat dit opvalt. Alle drie delen worden base64url-gecodeerd voordat ze samengevoegd worden:

{
  "header": {
    "alg": "HS256",
    "typ": "JWT"
  },
  "payload": {
    "sub": "1234567890",
    "name": "Jan Janssen",
    "admin": true
  },
  "signature": "HMACSHA256(
    base64UrlEncode(header) + '.' +
    base64UrlEncode(payload),
    secret_key
  )"
}

Belangrijk om te weten is dat de payload leesbaar is voor iedereen die het token heeft. Base64-encoding is geen encryptie. Zet daarom nooit gevoelige informatie zoals wachtwoorden of creditcardnummers in een JWT. De signature voorkomt wel dat iemand de inhoud kan wijzigen zonder dat je het merkt. Probeer iemand de admin claim van false naar true te veranderen, dan klopt de signature niet meer en wordt het token afgewezen.

Deze opzet maakt JWT's perfect voor gedistribueerde systemen. Elke server die de secret key kent, kan tokens valideren zonder contact op te nemen met een centrale database. Dat scheelt enorm in complexiteit en response tijd, vooral als je applicatie groeit naar meerdere regio's of load balancers.

Implementatie in Laravel

Mijn go-to package voor JWT's in Laravel is tymon/jwt-auth. Na installatie via Composer configureer je de middleware en ben je klaar om tokens te genereren. Wanneer een gebruiker succesvol inlogt, maak ik direct een token aan:

use Tymon\JWTAuth\Facades\JWTAuth;

$token = JWTAuth::fromUser($user);

De client krijgt dit token terug en stuurt het bij elk vervolgverzoek mee in de Authorization header. Middleware aan de serverkant valideert automatisch elk inkomend token voordat de eigenlijke route wordt uitgevoerd:

use Closure;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;

public function handle($request, Closure $next)
{
    try {
        $token = $request->header('Authorization');
        if (!$token) {
            throw new TokenInvalidException;
        }
        $user = JWTAuth::parseToken($token)->authenticate();
        if (!$user) {
            throw new TokenInvalidException;
        }
    } catch (TokenExpiredException $e) {
        return response()->json(['error' => 'Token is verlopen'], 401);
    } catch (TokenInvalidException $e) {
        return response()->json(['error' => 'Ongeldig token'], 401);
    }
    return $next($request);
}

JavaScript clients maken vervolgens requests met het Bearer token in de header:

fetch('/api/gebruikers', {
    method: 'GET',
    headers: {
        'Authorization': 'Bearer ' + token
    }
})

Zolang het token geldig is, krijgt de client toegang tot de gevraagde resource. Verloopt het token of wordt het gemanipuleerd, dan volgt direct een 401 response. Geen verdere uitleg, geen hints voor aanvallers. Clean en duidelijk.

Autorisatie via claims

Authenticatie beantwoordt de vraag wie iemand is. Autorisatie bepaalt wat diegene mag doen. JWT's maken dit tweede deel elegant oplosbaar door claims direct in het token op te nemen. Een admin: true in de payload betekent dat de gebruiker beheerdersrechten heeft. Een role: editor geeft aan welke functie iemand heeft. Claims lees je uit aan de serverkant voordat je een actie toestaat.

Voordeel hiervan is dat je niet voor elke request naar de database hoeft om rollen op te zoeken. Alle benodigde autorisatie-informatie zit al in het token. Nadeel is wel dat deze informatie niet real-time updates. Promoveer je een gebruiker tot admin, dan blijft zijn huidige token nog steeds de oude rol claimen tot het verloopt. Voor de meeste toepassingen is dat acceptabel, maar houd er wel rekening mee.

Custom claims toevoegen doe je tijdens het genereren van het token. Je kunt zowel standaard claims gebruiken zoals sub (subject) en exp (expiration) als je eigen velden definiëren. Houd wel de token size in de gaten: hoe meer claims, hoe groter het token, en dat betekent meer bytes bij elke request.

Veilig omgaan met tokens

Secret key kwaliteit is cruciaal voor JWT-beveiliging. Een zwakke key breekt het hele systeem open. Genereer altijd een lange, willekeurige string van minstens 256 bits. Sla deze op in je .env bestand en hardcode hem nooit in je code. Laravel's php artisan jwt:secret commando doet dit automatisch voor je.

Token blacklisting is complexer dan het lijkt. Standaard blijven JWT's geldig tot ze verlopen, ook als een gebruiker uitlogt of het account wordt geblokkeerd. Wil je tokens direct ongeldig maken, dan moet je een blacklist bijhouden:

use Illuminate\Support\Facades\Cache;

public function handle($request, Closure $next)
{
    $token = $request->header('Authorization');
    if (cache()->has('blacklist:' . $token)) {
        return response()->json(['error' => 'Token is ingetrokken'], 401);
    }
    // ...
}

Paradoxaal genoeg verlies je hiermee wel het stateless voordeel van JWT's. Je houdt namelijk weer server-side state bij. Praktische oplossing die ik hanteef: korte token lifetimes van 15 tot 60 minuten, gecombineerd met refresh tokens voor langere sessies. Zo beperk je het aanvalsoppervlak zonder de architectuur te compliceren.

Opslag aan de client-kant verdient ook aandacht. LocalStorage is kwetsbaar voor XSS-aanvallen. HTTP-only cookies zijn veiliger maar werken niet altijd goed met mobile apps. Voor webapplicaties gebruik ik meestal een combinatie: korte access tokens in memory en refresh tokens in HTTP-only cookies. Mobile apps bewaren tokens vaak in secure storage van het platform zelf.

OAuth 2.0 en OpenID Connect bekijk ik inmiddels ook voor complexere projecten, maar voor de meeste API's die ik bouw volstaat een goed geconfigureerde JWT-implementatie. Het is mature technologie die zijn waarde bewezen heeft en door vrijwel alle moderne frameworks ondersteund wordt.