Valkuilen bij JWT- en sessiebeveiliging, en hoe je ze vermijdt
De JWT- en sessiefouten die tot accountovername leiden: algorithm confusion, zwakke secrets, ontbrekende claimcontroles, tokenopslag, rotatie en echt uitloggen.
· 7 min. leestijd · Lina Source LLC
JSON Web Tokens zijn een redelijk formaat met een lange lijst scherpe randen. Het token zelf is zelden het probleem. De bugs zitten in hoe het wordt geverifieerd, waar het wordt opgeslagen, hoe lang het leeft en wat er gebeurt als een gebruiker uitlogt. Elk van die fouten heeft dezelfde uitkomst: iemand heeft een token dat hij niet hoort te hebben, en je server accepteert het.
De twee zwakheden die het vaakst opduiken, zijn CWE-347, onjuiste verificatie van een cryptografische handtekening, en CWE-613, onvoldoende sessieverloop. De voorbeelden hieronder gebruiken jose, een veelgebruikte JavaScript-bibliotheek voor JWT’s die draait in Node.js, edge-runtimes en browsers.
Decoderen is niet verifiëren
De meest directe vorm van CWE-347 is claims uit een token lezen zonder de handtekening te controleren. Elke JWT-bibliotheek heeft een decodeerfunctie om te debuggen, en die duikt vaker in auth-middleware op dan zou moeten. Een gedecodeerd token is gewoon base64 dat iedereen kan schrijven. In JavaScript-codebases verstopt het patroon zich vaak in een kleine helper die het token op punten splitst en JSON.parse op het middelste deel draait. Het werkt in elke test, omdat de testtokens geldig zijn, en het accepteert elk vervalst token in productie.
import { decodeJwt, jwtVerify } from 'jose';
// Kwetsbaar: iedereen kan een token maken met sub op een willekeurige gebruikers-ID
const claims = decodeJwt(token);
const userId = claims.sub;
// Correct: eerst worden handtekening, algoritme en claims gecontroleerd
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none en algorithm confusion
De JWT-header zegt welk algoritme het token heeft ondertekend, en de aanvaller bepaalt de header. Daaruit volgen twee klassieke aanvallen. De eerste is alg op none: een niet-ondertekend token dat sommige oudere bibliotheken als geldig accepteerden. De tweede is algorithm confusion: een server die RS256 verwacht maar de header het algoritme laat kiezen, kan een HS256-token krijgen dat is ondertekend met de publieke sleutel van de server als HMAC-secret. Die publieke sleutel is publiek, dus de aanvaller kan alles ondertekenen.
Moderne bibliotheken verdedigen tegen beide, en jose weigert onbeveiligde tokens in jwtVerify en controleert of het sleuteltype bij het algoritme past. Vertrouw niet alleen op standaardinstellingen. Pin de algoritmelijst expliciet bij elke verify-aanroep, zodat een toekomstige refactor of bibliotheekwissel hem niet stilletjes kan oprekken. Accepteer je meer dan één algoritme, bijvoorbeeld tijdens een sleutelmigratie, noem ze dan allebei expliciet en gebruik voor elk een aparte sleutel. Zet bij sleutelrotatie een kid in de header en kies de sleutel op kid uit je eigen lijst, nooit uit een URL die in de tokenheader staat.
Zwakke ondertekeningssecrets
Een HS256-token wordt met een gedeeld secret ondertekend. Is dat secret kort of te raden, zoals 'secret', de naam van de app of een waarde uit een tutorial, dan kan een aanvaller met één geldig token het offline brute-forcen met alledaagse tools en daarna tokens voor elke gebruiker ondertekenen. Offline raden kent geen rate limit.
- Gebruik minstens 32 willekeurige bytes voor HS256, gegenereerd met bijvoorbeeld openssl rand -base64 32.
- Laad het secret uit de omgeving of een secret manager, en faal bij het opstarten als het ontbreekt of te kort is.
- Commit het nooit, en roteer het als het ooit wel is gecommit.
- Moeten meerdere services tokens verifiëren terwijl maar één ze mag uitgeven, gebruik dan een asymmetrisch algoritme zoals RS256 of EdDSA, zodat verifiers alleen de publieke sleutel hebben.
Valideer exp, aud en iss
Een geldige handtekening bewijst alleen wie het token heeft uitgegeven. De claims bepalen of het voor jou bedoeld is en of het nog actueel is. Een token zonder vervaldatum is eeuwig geldig. Een token dat voor je mobiele API is uitgegeven, hoort niet door je adminservice te worden geaccepteerd omdat ze allebei dezelfde identity provider vertrouwen. Daar zijn aud en iss voor.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose controleert exp wanneer die aanwezig is, maar een token zónder exp zou anders gewoon slagen. De optie requiredClaims dicht dat gat. Verifieer tokens van een externe identity provider tegen zijn gepubliceerde sleutelset met createRemoteJWKSet, en pin nog steeds algoritmen, issuer en audience.
localStorage of httpOnly-cookies
Een token in localStorage opslaan maakt het leesbaar voor elk script op de pagina. Eén XSS-bug, of één gecompromitteerd script van derden, en het token kan naar een aanvaller worden gestuurd en overal worden gebruikt totdat het verloopt.
Een httpOnly-cookie kan niet door JavaScript worden gelezen. XSS blijft ernstig, want geïnjecteerd script kan requests namens de gebruiker doen zolang de pagina open is, maar het kan geen langlevende credential stelen en later opnieuw afspelen. Voor browserapps die met hun eigen backend praten, zijn cookies de betere standaard. Voor een single-page app die een aparte API aanroept, is het access token alleen in het geheugen houden en het refresh token in een httpOnly-cookie een redelijke middenweg.
// Express: sessiecookie met veilige attributen
res.cookie('__Host-session', accessToken, {
httpOnly: true, // niet leesbaar vanuit JavaScript
secure: true, // alleen HTTPS; vereist door het __Host--voorvoegsel
sameSite: 'lax', // niet meegestuurd bij cross-site POSTs
path: '/', // vereist door het __Host--voorvoegsel
maxAge: 10 * 60 * 1000, // milliseconden, gelijk aan de tokenlevensduur
});Het voorvoegsel __Host- vertelt de browser de cookie te weigeren tenzij hij Secure is, path op / heeft en geen Domain-attribuut, wat voorkomt dat een gecompromitteerd subdomein hem overschrijft.
SameSite en wat het niet afdekt
SameSite=Lax blokkeert de cookie bij cross-site POST-requests en bij het laden van subresources, wat de meeste klassieke CSRF wegneemt. De cookie gaat nog wel mee bij navigaties op topniveau met GET, dus elk GET-endpoint dat de status wijzigt, is blootgesteld. Strict blokkeert die ook, maar logt gebruikers ook uit wanneer ze vanuit e-mail of een andere site een link naar je app volgen. None schakelt de bescherming uit en vereist Secure.
Lax plus de regel dat GET-requests nooit de status wijzigen, is een solide basis. Voeg bij gevoelige mutaties een controle op de Origin-header of een CSRF-token toe. Onthoud dat SameSite alle subdomeinen van je registreerbare domein als same-site beschouwt, dus een kwetsbaar subdomein kan nog steeds requests vervalsen.
Rotatie, intrekken en echt uitloggen
Een stateless JWT kan niet worden ingetrokken voordat hij verloopt; dat is de prijs voor het niet raadplegen van een database bij elk request. CWE-613 beschrijft wat er gebeurt als je die afweging negeert: uitloggen, wachtwoordwijzigingen en accountblokkades die de toegang niet echt beëindigen. Een account verwijderen, een rol wijzigen en iemand uit een team halen zijn ook intrekkingsmomenten, en elk daarvan hoort bij het volgende request effect te hebben, niet pas wanneer het token verloopt.
- Houd access tokens kortlevend, in de orde van minuten, niet dagen.
- Sla refresh tokens aan de serverkant op, gehasht, en roteer ze bij elk gebruik.
- Wordt een refresh token dat al geroteerd was opnieuw gebruikt, behandel dat dan als diefstal en trek de hele tokenfamilie in.
- Voeg een tokenversie toe aan de gebruikersrij en aan het token; verhoog die bij overal-uitloggen, wachtwoordwijziging of blokkade.
- Genereer de sessie-identifier opnieuw bij het inloggen om session fixation te voorkomen.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// Een verhoogde versie of een geblokkeerd account beëindigt de toegang meteen
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}Die lookup brengt één databaselezing per request terug, en dat is de eerlijke prijs van intrekken. Veel apps zijn eenvoudiger en veiliger met een ondoorzichtige sessie-ID in een cookie en een sessietabel. Uitloggen betekent dan een rij verwijderen. Gebruik JWT’s waar hun eigenschappen echt helpen, zoals kortlevende tokens tussen services, en niet omdat ze de standaard in een tutorial zijn.
Wat je ook kiest, uitloggen moet op de server gebeuren. De cookie wissen of het token in de browser verwijderen haalt alleen de kopie van de gebruiker weg; een gestolen kopie blijft werken totdat de server hem weigert. Verwijder bij het uitloggen de sessie of het refresh token aan de serverkant, wis de cookie met dezelfde naam, hetzelfde pad en dezelfde attributen waarmee hij is gezet, en verhoog de tokenversie als de gebruiker overal wilde uitloggen. Een wachtwoordherstel hoort ook elke andere sessie te beëindigen.
Een korte reviewchecklist
- Zoek naar decode-aanroepen in authenticatiepaden.
- Bevestig dat elke verify-aanroep algoritmen, issuer en audience pint en exp vereist.
- Controleer hoe het ondertekeningssecret wordt gegenereerd, geladen en bij het opstarten gevalideerd.
- Zoek uit waar tokens in de browser worden opgeslagen.
- Test dat uitloggen, een wachtwoordwijziging en een accountblokkade bestaande sessies beëindigen.
Deze controles draaien vooral om codepaden van begin tot eind lezen, en zo pakt CodeAuditAgent een audit ook aan: bevindingen komen met de geciteerde regel, de CWE, een exploitscenario en een patch, zodat een ontbrekende audience-controle in minuten te bevestigen en te herstellen is.