JWT Decoder JavaScript — atob(), TextDecoder & jose
Gebruik de gratis JWT Decoder direct in je browser — geen installatie nodig.
JWT Decoder online uitproberen →Elke authenticatiestroom die ik heb gebouwd, bereikt uiteindelijk hetzelfde punt: je hebt een JWT in een cookie, een header of een OAuth-callback-URL, en je moet lezen wat er in zit. Een JWT-decoder in JavaScript vereist geen npm-pakket. De header en payload van het token zijn gewoon Base64url-gecodeerde JSON, en zowel de browser als Node.js worden geleverd met alles wat nodig is om ze te decoderen. Deze gids behandelt de volledige JavaScript text decoder-pipeline voor JWT's: het token splitsen, normaliseren van base64url naar standaard Base64, atob() en TextDecoder voor correcte UTF-8-verwerking, Node.js Buffer.from(), handtekeningverificatie met jose, en de veelvoorkomende fouten die ontwikkelaars dagelijks struikelen. Voor een snelle eenmalige inspectie, probeer de online JWT Decoder in plaats daarvan. Alle voorbeelden richten zich op ES2020+ en Node.js 18+.
- ✓Splits de JWT op "." — index 0 is de header, index 1 is de payload, index 2 is de handtekening.
- ✓atob() decodeert Base64 maar geeft Latin-1 terug, niet UTF-8. Gebruik TextDecoder of Buffer.from() voor niet-ASCII-claims.
- ✓Buffer.from(segment, "base64url") verwerkt base64url native in Node.js — geen handmatige tekenvervanging nodig.
- ✓Decoderen is GEEN verificatie. Vertrouw nooit op claims van een gedecodeerde JWT zonder de handtekening server-side te controleren.
- ✓De jose-bibliotheek doet beide: het verifieert HS256/RS256/ES256-handtekeningen en geeft de gedecodeerde payload terug in één aanroep.
Wat is JWT-decodering?
Een JSON Web Token bestaat uit drie Base64url-gecodeerde segmenten gescheiden door punten. Het eerste segment is de header, het tweede is de payload (de claims waar je echt om geeft), en het derde is de cryptografische handtekening. De header is een klein JSON-object dat het token zelf beschrijft. Het belangrijkste veld is alg — het ondertekeningsalgoritme (bijv. HS256, RS256, ES256). Het veld typ is bijna altijd "JWT", en het optionele kid veld identificeert welke sleutel is gebruikt om het token te ondertekenen — cruciaal wanneer een identiteitsprovider sleutels roteert en een JWKS-eindpunt publiceert met meerdere publieke sleutels.
De payload bevat de claims. RFC 7519 definieert zeven geregistreerde claimnamen: sub (subject — gewoonlijk het gebruikers-ID), iss (issuer — de URL van de auth-server), aud (audience — de API waarvoor het token bedoeld is), iat (uitgegeven-op tijdstempel), exp (vervaltijdstempel), nbf (niet-voor tijdstempel), en jti (JWT ID — gebruikt om replay-aanvallen te voorkomen). Alle tijdstempels zijn Unix epoch-seconden, geen milliseconden. Het handtekeningsegment is ruwe binaire data — een gecodeerde HMAC-digest of een asymmetrische digitale handtekening. Het is Base64url-gecodeerd net als de andere segmenten, maar de bytes zijn geen JSON en hebben geen voor mensen leesbare structuur.
In de praktijk decodeer je JWT's in JavaScript om drie veelvoorkomende redenen. Ten eerste, debuggen: je hebt een token van een OAuth-stroom of een testomgeving en wilt bevestigen dat de claims overeenkomen met wat de auth-server had moeten uitgeven. Ten tweede, gebruikersclaims lezen voor weergavedoeleinden aan de clientzijde — de naam van de ingelogde gebruiker, avatar-URL of rolbadge tonen vanuit de token-payload zonder een extra API-aanroep. Ten derde, vervaldatum controleren voor een vernieuwingspoging: als exp binnen de volgende 60 seconden is, activeer een stille vernieuwing voor de volgende API-aanroep in plaats van te wachten op een 401-respons.
Decoderen controleert niet of het token geldig of gemanipuleerd is. Dat is een aparte bewerking genaamd verificatie, die het HMAC-geheim of de RSA/ECDSA publieke sleutel vereist. Iedereen kan een JWT decoderen. Alleen de houder van de juiste sleutel kan er een verifiëren. Dit onderscheid struikelt veel ontwikkelaars, vooral bij het bouwen van client-side auth-stromen waarbij gedecodeerde claims worden weergegeven maar nooit mogen worden vertrouwd voor autorisatiebeslissingen zonder een geverifieerde backend-controle.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Header
{ "alg": "HS256" }
// Payload
{
"sub": "usr_921f",
"role": "admin",
"iat": 1711610000
}atob() + TextDecoder — Browser-native JWT Decodering
De browser-native pipeline voor het decoderen van een JWT heeft vier stappen. Ten eerste, splits het token op "." om de drie segmenten te krijgen. Ten tweede, normaliseer het base64url-segment door - te vervangen door + en _ door /, en aanvullen met = totdat de lengte een veelvoud van 4 is. Ten derde, roep atob() aan om de Base64 te decoderen naar een binaire string. Ten vierde, converteer de binaire string naar correcte UTF-8 met TextDecoder. Die laatste stap is belangrijk omdat atob() Latin-1 teruggeeft. Multi-byte tekens — emoji, CJK-tekst, geaccentueerde tekens buiten het Latin-1-bereik — komen verminkt terug zonder de JavaScript text decoder-stap.
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
function decodeJwtPayload(jwt) {
const base64Url = jwt.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
const json = new TextDecoder("utf-8").decode(bytes);
return JSON.parse(json);
}
console.log(decodeJwtPayload(token));
// { sub: "usr_921f", role: "admin", iat: 1711610000 }De opvulstap is gemakkelijk over het hoofd te zien. JWT's verwijderen afsluitende = tekens uit hun Base64url-segmenten omdat de JWT-specificatie (RFC 7515) base64url definieert zonder opvulling. Maar atob() in sommige browser-engines gooit een InvalidCharacterError als de invoerlengte niet deelbaar is door 4. Defensief opvullen met padEnd() vermijdt dat randgeval in alle omgevingen. Hier is een herbruikbare versie die zowel header als payload decodeert in afzonderlijke objecten:
function decodeBase64Url(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}
function decodeJwt(token) {
const [headerB64, payloadB64] = token.split(".");
return {
header: JSON.parse(decodeBase64Url(headerB64)),
payload: JSON.parse(decodeBase64Url(payloadB64)),
};
}
const { header, payload } = decodeJwt(token);
console.log("Algorithm:", header.alg); // "HS256"
console.log("Subject:", payload.sub); // "usr_921f"
console.log("Role:", payload.role); // "admin"Zodra je deze twee functies hebt, is het de moeite waard ze in een gedeelde hulpmodule te plaatsen in plaats van de logica over bestanden te kopiëren. Een src/lib/jwt.ts of utils/jwt-decode.ts bestand met een getypeerde terugkeervorm maakt de bedoeling duidelijk in de codebase. In TypeScript kun je het terugkeertype opgeven als { header: JwtHeader; payload: JwtPayload } waarbij JwtHeader bevat alg, typ, en optioneel kid, en JwtPayload de RFC 7519-geregistreerde claims uitbreidt met een indexhandtekening voor aangepaste claims. Het centraliseren van de decodelogica betekent dat wanneer je later foutafhandeling wilt toevoegen (het opvangen van misvormde segmenten) of telemetrie (het loggen van decodefouten), je maar één plek hoeft bij te werken.
TextDecoder-stap is wat deze pipeline veilig maakt voor niet-ASCII-claims. Zonder deze stap geeft atob() een Latin-1 string terug waarbij multi-byte UTF-8-reeksen verspreid zijn over tekens. Je ziet rommel in plaats van emoji of CJK-tekst. Pipe altijd door new TextDecoder("utf-8") na atob().UTF-8 JWT-claims decoderen met multi-byte tekens
JWT-payloads zijn UTF-8 JSON gecodeerd als base64url. De meeste payloads bevatten alleen ASCII-velden zoals gebruikers-ID's en tijdstempels, dus ontwikkelaars merken nooit dat atob() Latin-1 teruggeeft in plaats van UTF-8. Het probleem verschijnt zodra een claim emoji, Japanse tekens, Cyrillisch schrift of een codepunt boven U+00FF bevat. Het JavaScript decode UTF-8-patroon vereist het converteren van de binaire string naar een byte-array, vervolgens door TextDecoder laten lopen.
// Een JWT-payload simuleren met emoji en CJK-tekens
const payloadObj = {
sub: "usr_e821",
display_name: "田中太郎",
team: "Platform 🚀",
region: "ap-northeast-1"
};
// Coderen: object → JSON → UTF-8 bytes → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Decoderen: base64url → base64 → binaire string → bytes → UTF-8 string
const base64Std = base64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64Std);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder("utf-8").decode(bytes);
const result = JSON.parse(decoded);
console.log(result.display_name); // "田中太郎" — correct
console.log(result.team); // "Platform 🚀" — correctEr is een legacy fallback-patroon dat je in oudere codebases ziet dat decodeURIComponent combineert met een percent-encoding truc. Deze JavaScript decodeURIComponent-aanpak werkt omdat het elke byte herencodeert als een percent-hex-paar, waarna decodeURIComponent de multi-byte UTF-8-reeksen hermonteert:
function decodeBase64UrlLegacy(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
// Converteer elk teken naar %XX hex, dan hermonteert decodeURIComponent UTF-8
const utf8 = decodeURIComponent(
binary.split("").map(c =>
"%" + c.charCodeAt(0).toString(16).padStart(2, "0")
).join("")
);
return utf8;
}
// Werkt voor niet-ASCII-claims zonder TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));decodeURIComponent(escape(atob(segment)))-patroon tegenkomen in oudere JWT-hulpfragmenten. De escape()-functie is verouderd en niet-standaard. Vervang het door de TextDecoder-aanpak die hierboven wordt getoond. Het JavaScript unescape decoder-patroon heeft hetzelfde probleem: unescape() is verouderd. Beide functies kunnen worden verwijderd uit toekomstige JavaScript-engines.JWT Decode Pipeline — Stappenreferentie
Elke stap in de browser-native JWT-decoderingspipeline, met de gebruikte JavaScript-API en wat het oplevert:
Het Node.js-equivalent vouwt stappen 2 tot en met 4 samen in één aanroep: Buffer.from(segment, "base64url").toString("utf-8"). De "base64url" coderingsoptie verwerkt de alfabetconversie en opvulling intern.
Buffer.from() — De Node.js String Decoder voor JWT's
Node.js heeft een veel eenvoudigere weg. De Buffer klasse accepteert een "base64url" codering rechtstreeks, zodat je de handmatige tekenvervanging en opvulling overslaat. Dit is het JavaScript string decoder-pad voor server-side code. Één regel zet een JWT-segment om in een UTF-8 string, en het verwerkt multi-byte tekens correct zonder extra stappen.
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsIm9yZyI6ImFjbWUtY29ycCIsInJvbGUiOiJiaWxsaW5nIiwiaWF0IjoxNzExNjEwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
function decodeJwt(jwt) {
const segments = jwt.split(".");
return {
header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
};
}
const { header, payload } = decodeJwt(token);
console.log(header);
// { alg: "HS256" }
console.log(payload);
// { sub: "usr_921f", org: "acme-corp", role: "billing", iat: 1711610000 }Dit is de aanpak die ik gebruik in elk Node.js-project. Het is korter, sneller, en verwerkt UTF-8 al correct. Geen TextDecoder nodig, geen tekenvervanging, geen opvulmathematiek. De Buffer klasse is een JavaScript string decoder die het base64url-alfabet native verwerkt, wat een hele klasse bugs gerelateerd aan tekenvervanging elimineert. Als je code in zowel de browser als Node.js moet draaien, zie de FAQ onderaan voor een isomorfe wrapperfunctie die de omgeving detecteert tijdens runtime.
Hier is een meer compleet voorbeeld dat laat zien hoe je veelvoorkomende JWT-claims extraheert en tijdstempels converteert naar leesbare datums, wat het patroon is dat je het vaakst gebruikt in middleware en API-route handlers:
function inspectToken(token) {
const segments = token.split(".");
if (segments.length !== 3) {
throw new Error("Not a valid JWT — expected 3 dot-separated segments");
}
const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));
const inspection = {
algorithm: header.alg,
tokenType: header.typ || "JWT",
subject: payload.sub,
issuer: payload.iss || "(not set)",
audience: payload.aud || "(not set)",
issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : "(not set)",
expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : "(never)",
isExpired: payload.exp ? payload.exp < Math.floor(Date.now() / 1000) : false,
customClaims: Object.keys(payload).filter(
k => !["sub", "iss", "aud", "iat", "exp", "nbf", "jti"].includes(k)
),
};
return inspection;
}
console.log(inspectToken(process.env.ACCESS_TOKEN));
// {
// algorithm: "RS256",
// tokenType: "JWT",
// subject: "usr_921f",
// issuer: "https://auth.internal",
// audience: "billing-api",
// issuedAt: "2026-03-10T14:00:00.000Z",
// expiresAt: "2026-03-10T15:00:00.000Z",
// isExpired: true,
// customClaims: ["role", "scope", "org"]
// }In productie Node.js-diensten verschijnt het Buffer.from() decodepatroon op drie terugkerende plekken. De eerste is request logging middleware: je decodeert de inkomende Authorization header om userId en org toe te voegen aan elke gestructureerde logvermelding zonder een extra netwerkreis naar de auth-server. De tweede is debuggen: je drukt gedecodeerde token-claims af naar de console tijdens de ontwikkeling om te bevestigen dat de juiste scopes zijn uitgegeven voordat je testassertions schrijft. De derde is proactieve tokenvernieuwing in API-gateways. In plaats van een token upstream door te sturen en te wachten tot de downstream dienst een 401 teruggeeft wanneer het token verloopt mid-request, decodeert de gateway het token aan de rand, leest de exp claim, en activeert een vernieuwing als de vervaldatum binnen de volgende 30 seconden is. Dit elimineert een klasse van tijdelijke auth-fouten die moeilijk te reproduceren en frustrerend te debuggen zijn.
"base64url"-codering werd toegevoegd in Node.js 15.7.0. Als je vastzit op Node.js 14 of eerder, val terug op Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") dat op dezelfde manier werkt maar de handmatige tekenvervanging vereist.JWT decoderen uit een bestand en API-respons
Twee scenario's komen voortdurend voor. Het eerste is het lezen van een JWT uit een lokaal bestand: een opgeslagen token tijdens de ontwikkeling, een testfixture of een bestand gedumpt tijdens een incident voor post-mortem analyse. Het tweede is het extraheren van een JWT uit een HTTP-respons, gewoonlijk het access_token veld in de body van een OAuth-tokenrespons of een Authorization header. Beide hebben foutafhandeling nodig omdat misvormde tokens, afgebroken bestanden en netwerkfouten dagelijkse realiteit zijn. Een token dat vorige week geldig was, kan afsluitende witruimte of newlines hebben van kopiëren en plakken. Een responsbody kan HTML zijn in plaats van JSON als de auth-server een foutpagina heeft teruggestuurd.
JWT lezen uit een bestand (Node.js)
import { readFileSync } from "node:fs";
function decodeJwtFromFile(filePath) {
const raw = readFileSync(filePath, "utf-8").trim();
const segments = raw.split(".");
if (segments.length !== 3) {
throw new Error(`Invalid JWT: expected 3 segments, got ${segments.length}`);
}
try {
return {
header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
};
} catch (err) {
throw new Error(`Failed to decode JWT from ${filePath}: ${err.message}`);
}
}
try {
const { header, payload } = decodeJwtFromFile("./test-fixtures/access-token.txt");
console.log("Algorithm:", header.alg);
console.log("Expires:", new Date(payload.exp * 1000).toISOString());
} catch (err) {
console.error(err.message);
}JWT extraheren uit een API-respons (fetch)
async function fetchAndDecodeToken(loginUrl, credentials) {
const response = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status} ${response.statusText}`);
}
const { access_token } = await response.json();
if (!access_token || access_token.split(".").length !== 3) {
throw new Error("Response does not contain a valid JWT");
}
const payload = access_token.split(".")[1];
const json = Buffer.from(payload, "base64url").toString("utf-8");
return JSON.parse(json);
}
// Gebruik
try {
const claims = await fetchAndDecodeToken(
"https://auth.internal/oauth/token",
{ username: "deploy-bot", password: process.env.DEPLOY_TOKEN }
);
console.log("Token subject:", claims.sub);
console.log("Token scopes:", claims.scope);
console.log("Expires at:", new Date(claims.exp * 1000).toISOString());
} catch (err) {
console.error("Token decode error:", err.message);
}JWT Decoderen via de Commandoregel
Soms wil je gewoon even naar een token kijken vanuit de terminal zonder een script te schrijven. Node.js is beschikbaar op de meeste ontwikkelaarsmachines, dus een one-liner werkt goed. jq verzorgt de nette opmaak.
# JWT-payload decoderen met Node.js one-liner
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
| cut -d. -f2 \
| node -e "process.stdin.on('data', d => console.log(JSON.parse(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8'))))"
# Pipe naar jq voor nette uitvoer
echo "$JWT_TOKEN" | cut -d. -f2 \
| node -e "process.stdin.on('data', d => process.stdout.write(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8')))" \
| jq .
# Zowel header als payload decoderen
echo "$JWT_TOKEN" | node -e "
process.stdin.on('data', d => {
const parts = d.toString().trim().split('.');
console.log('Header:', JSON.parse(Buffer.from(parts[0], 'base64url').toString()));
console.log('Payload:', JSON.parse(Buffer.from(parts[1], 'base64url').toString()));
});
"Als je de voorkeur geeft aan pure bash zonder Node.js, pipe het segment door base64 -d na het corrigeren van de base64url-tekens met tr:
# Pure bash: JWT-payload decoderen zonder Node.js echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq . # macOS variant (base64 -D in plaats van -d) echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .
Voor snelle visuele inspectie zonder terminal, plak je token in de ToolDeck JWT Decoder voor een zij-aan-zij overzicht van alle drie segmenten met kleurgecodeerde claimlabels en vervaldatumstatus.
jose — Verificatie en Decodering in Één Bibliotheek
Voor productie-authenticatiemiddleware heb je handtekeningverificatie nodig, niet alleen decodering. De jose bibliotheek is de beste optie. Het werkt in zowel Node.js als browsers (via de Web Crypto API), ondersteunt HS256, RS256, ES256, EdDSA en JWE (versleutelde tokens), en heeft nul native afhankelijkheden. Installeer met npm install jose.
import * as jose from "jose";
const secret = new TextEncoder().encode("k8s-webhook-signing-secret-2026");
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInNjb3BlIjoiYmlsbGluZzpyZWFkIiwiaWF0IjoxNzExNjEwMDAwLCJleHAiOjE3MTE2MTM2MDB9.abc123";
try {
const { payload, protectedHeader } = await jose.jwtVerify(token, secret);
console.log("Algorithm:", protectedHeader.alg); // "HS256"
console.log("Subject:", payload.sub); // "usr_921f"
console.log("Scope:", payload.scope); // "billing:read"
} catch (err) {
if (err.code === "ERR_JWT_EXPIRED") {
console.error("Token expired at:", err.payload.exp);
} else {
console.error("Verification failed:", err.message);
}
}import * as jose from "jose";
// Haal de publieke sleutelset op bij de identiteitsprovider
const jwks = jose.createRemoteJWKSet(
new URL("https://auth.internal/.well-known/jwks.json")
);
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Missing token" });
}
try {
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: "https://auth.internal",
audience: "billing-api",
});
// payload.sub, payload.scope, etc. zijn nu geverifieerd
req.userId = payload.sub;
} catch (err) {
return res.status(401).json({ error: "Invalid token" });
}Bij het kiezen tussen jose en het oudere jsonwebtoken pakket is het belangrijkste verschil de runtime-scope. jsonwebtoken is alleen Node.js — het steunt op de crypto ingebouwde module en kan niet worden gebundeld voor de browser. jose is volledig isomorf: het gebruikt de Web Crypto API, die beschikbaar is in alle moderne browsers, Node.js 16+, Deno, Bun en Cloudflare Workers. Als je auth-logica in een Next.js middleware-bestand staat (dat draait in de Edge Runtime), of in een Cloudflare Worker, of in een gedeeld hulpprogramma dat wordt geïmporteerd door zowel server- als clientcode, jose is de juiste keuze omdat het nul native afhankelijkheden heeft en installeert zonder een bouwstap. jsonwebtoken blijft redelijk voor pure Node.js-serverapplicaties waar je zijn bredere ecosysteem van ondertekeningshulpmiddelen nodig hebt en je niet van plan bent de code in een edge-omgeving te draaien. In een nieuw project in 2026, kies standaard voor jose tenzij je een specifieke reden hebt om de voorkeur te geven aan de oudere API.
Als je alleen decoderen zonder verificatie nodig hebt, biedt jose jose.decodeJwt(token) dat de payload teruggeeft en jose.decodeProtectedHeader(token) voor de header. Dit zijn handige functies die de Base64url-decodering intern uitvoeren. Maar de hele reden om jose te gebruiken is dat je zelden zou moeten decoderen zonder ook te verifiëren. Als je aan de clientzijde bent en alleen de weergavenaam of avatar-URL van de gebruiker uit de token-claims wilt tonen, is alleen decoderen prima. Aan de serverzijde, verifieer altijd. Ik heb productiesystemen gezien die JWT-claims decodeerden voor toegangscontrolebeslissingen zonder de handtekening te controleren, en dat is een open deur voor elke aanvaller die het JWT-formaat begrijpt.
import * as jose from "jose";
// Alleen decoderen: geen geheim nodig, geen verificatie
const payload = jose.decodeJwt(token);
console.log(payload.sub); // "usr_921f"
console.log(payload.scope); // "billing:read"
const header = jose.decodeProtectedHeader(token);
console.log(header.alg); // "HS256"
console.log(header.typ); // "JWT"
// Vervaldatum controleren zonder verificatie (client-side weergave)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
console.log("Token is verlopen — doorsturen naar inlogpagina");
}Terminaluitvoer met Syntaxisaccentuering
Bij het debuggen van JWT-tokens in een Node.js CLI-tool of tijdens een incident maakt kleurgecodeerde uitvoer een wezenlijk verschil. De chalk bibliotheek gecombineerd met JSON.stringify doet het werk. Installeer met npm install chalk.
import chalk from "chalk";
function printJwt(token) {
const segments = token.split(".");
if (segments.length !== 3) {
console.error(chalk.red("Invalid JWT: expected 3 segments"));
return;
}
const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));
console.log(chalk.bold.cyan("\n=== JWT Header ==="));
console.log(chalk.gray(JSON.stringify(header, null, 2)));
console.log(chalk.bold.green("\n=== JWT Payload ==="));
console.log(chalk.gray(JSON.stringify(payload, null, 2)));
// Vervaldatumstatus markeren
if (payload.exp) {
const expiresAt = new Date(payload.exp * 1000);
const isExpired = expiresAt < new Date();
console.log(
chalk.bold("\nVerloopt:"),
isExpired
? chalk.red(`VERLOPEN op ${expiresAt.toISOString()}`)
: chalk.green(`Geldig tot ${expiresAt.toISOString()}`)
);
}
console.log(chalk.dim("\nHandtekening: " + segments[2].substring(0, 20) + "..."));
}
printJwt(process.argv[2]);
// Uitvoeren: node jwt-debug.mjs "eyJhbGci..."JWT's verwerken uit grote logbestanden
Moderne API-infrastructuur genereert gestructureerde toegangslogboeken in NDJSON-formaat — één JSON-object per regel, waarbij elke regel het requestpad, de responsstatus, latentie en de gedecodeerde of ruwe Authorization header bevat. In een drukke dienst groeien deze bestanden snel: een gateway die 10.000 verzoeken per minuut verwerkt, produceert meer dan 14 miljoen logvermeldingen per dag. Beveiligings- en compliance-gevallen vereisen regelmatig het achteraf scannen van deze bestanden — elk verzoek identificeren dat is gedaan door een gecompromitteerd serviceaccount (post-incident analyse), bevestigen dat de tokens van een specifieke gebruiker zijn verlopen voor een data-toegangsvenster (compliance-audit), of de volledige set subjects extraheren die een gevoelig eindpunt hebben benaderd tijdens een onderhoudsvenster. Omdat een enkel logbestand meerdere gigabytes kan overschrijden, is het laden in het geheugen met readFileSync niet haalbaar. Node.js readline streams verwerken het bestand één regel tegelijk met constant geheugengebruik, waardoor het praktisch is om willekeurig grote logboeken te scannen op een standaard ontwikkelaarslaptop.
Je zult het "bestand te groot voor geheugen"-probleem niet tegenkomen met individuele JWT's, omdat een enkel token zelden meer dan een paar kilobytes is. Het scenario dat wel voorkomt is het scannen van een groot toegangslog of audittrail voor JWT-tokens, elk decoderen en specifieke claims extraheren. Node.js streams verwerken dit zonder het hele bestand te laden.
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function scanLogsForExpiredTokens(logPath) {
const fileStream = createReadStream(logPath, { encoding: "utf-8" });
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
let lineCount = 0;
let expiredCount = 0;
const nowSeconds = Math.floor(Date.now() / 1000);
for await (const line of rl) {
lineCount++;
try {
const entry = JSON.parse(line);
if (!entry.authorization_token) continue;
const segments = entry.authorization_token.split(".");
if (segments.length !== 3) continue;
const payload = JSON.parse(
Buffer.from(segments[1], "base64url").toString("utf-8")
);
if (payload.exp && payload.exp < nowSeconds) {
expiredCount++;
const expDate = new Date(payload.exp * 1000).toISOString();
console.log("Line " + lineCount + ": expired token for " + payload.sub + ", exp=" + expDate);
}
} catch {
// Sla misvormde regels over
}
}
console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}
scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function extractUniqueSubjects(logPath) {
const rl = createInterface({
input: createReadStream(logPath, { encoding: "utf-8" }),
crlfDelay: Infinity,
});
const subjects = new Set();
const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
for await (const line of rl) {
const matches = line.match(jwtRegex);
if (!matches) continue;
for (const token of matches) {
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString("utf-8")
);
if (payload.sub) subjects.add(payload.sub);
} catch {
// Geen geldig JWT
}
}
}
console.log(`Found ${subjects.size} unique subjects:`);
for (const sub of subjects) console.log(` ${sub}`);
}
extractUniqueSubjects("./logs/gateway-2026-03.log");readFileSync zal het geheugen vollopen en GC-pauzes veroorzaken. De readline-aanpak verwerkt één regel tegelijk met constant geheugengebruik.Veelgemaakte Fouten
Probleem: atob() geeft een Latin-1 string terug. Multi-byte UTF-8-tekens (emoji, CJK, geaccentueerde tekens) worden verspreid over tekens en komen verminkt terug.
Oplossing: Converteer de atob()-uitvoer naar een Uint8Array en geef deze vervolgens door aan new TextDecoder('utf-8').
// Breekt op niet-ASCII payload claims
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name verschijnt als "ç°ä¸å¤ªé\x83\x8E" in plaats van "田中太郎"const binary = atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"));
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
// display_name toont correct "田中太郎"Probleem: atob() gooit "InvalidCharacterError" omdat base64url - en _ gebruikt in plaats van + en /.
Oplossing: Vervang - door + en _ door / voor het aanroepen van atob(). Node.js Buffer.from() met 'base64url' verwerkt dit automatisch.
// Gooit: InvalidCharacterError: String contains an invalid character
const payload = atob(token.split(".")[1]);const segment = token.split(".")[1];
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const payload = atob(base64); // werkt nuProbleem: Iedereen kan een JWT maken met willekeurige payload. Decoderen leest alleen de data — het bewijst niet dat het token is uitgegeven door jouw auth-server.
Oplossing: Verifieer aan de serverzijde altijd de handtekening met jose.jwtVerify() of jsonwebtoken.verify(). Alleen decoderen is acceptabel voor client-side weergave van gebruikersclaims.
// GEVAARLIJK: gedecodeerd maar niet geverifieerd
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
grantAdminAccess(); // aanvaller kan dit vervalsen
}import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
grantAdminAccess(); // veilig — handtekening is geverifieerd
}Probleem: JWT exp is in seconden sinds epoch, maar Date.now() geeft milliseconden terug. De vergelijking zegt altijd dat het token geldig is omdat de millisecondentijdstempel 1000x groter is.
Oplossing: Deel Date.now() door 1000 en rond naar beneden af voor het vergelijken met exp.
// Bug: Date.now() is milliseconden, exp is seconden
if (payload.exp > Date.now()) {
console.log("Token is valid"); // altijd waar — fout!
}const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
console.log("Token is valid"); // correcte vergelijking
}JWT Decoderingsmethoden — Snelle Vergelijking
Gebruik atob() + TextDecoder voor browser-side decodering wanneer je alleen claims aan de gebruiker wilt weergeven. Gebruik Buffer.from() in Node.js-scripts en CLI-tools. Kies voor jose zodra je een handtekening moet verifiëren, wat geldt voor elke server-side auth-middleware. Het jwt-decode pakket is een lichtgewicht alternatief als je een één-functie API wilt voor alleen-decoderen in de browser. Voor snelle visuele inspectie zonder code te schrijven, plak je token in de JWT Decoder-tool.
Veelgestelde Vragen
Hoe decodeer ik een JWT-token in JavaScript zonder een bibliotheek?
Splits het token op ".", neem het tweede segment (de payload), normaliseer de base64url-codering door - te vervangen door + en _ door /, vul aan met =-tekens, roep dan atob() aan gevolgd door TextDecoder om de UTF-8 JSON-string te krijgen. Geef het resultaat door aan JSON.parse() en je hebt het claims-object. Geen npm-pakket nodig. Deze aanpak werkt in alle moderne browsers en in Node.js 18+. Als je ook de header wilt lezen, pas dan dezelfde decodeerstappen toe op het eerste segment. Houd er rekening mee dat dit de ruwe data geeft zonder enige handtekeningverificatie — behandel het resultaat als alleen-weergave tenzij je de handtekening server-side verifieert.
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);
const claims = JSON.parse(json);
console.log(claims);
// { sub: "usr_921f", role: "admin" }Wat is het verschil tussen atob() en Buffer.from() voor JWT-decodering?
atob() is een browser-API die standaard Base64 decodeert naar een Latin-1 binaire string. Het begrijpt base64url-codering niet rechtstreeks, dus je moet eerst de - en _-tekens vervangen. Buffer.from(segment, "base64url") is een Node.js-API die het base64url-alfabet native verwerkt en een Buffer teruggeeft waarop je .toString("utf-8") kunt aanroepen. Gebruik atob() in de browser, Buffer.from() in Node.js. Een derde optie — die langzamer is maar historisch veel voorkomt — is de decodeURIComponent percent-encoding truc, maar dat patroon steunt op de verouderde escape()-functie in sommige oudere fragmenten en moet worden vermeden in nieuwe code. Voor isomorfe code die in beide omgevingen draait, controleer op typeof Buffer !== "undefined" en vertakk dienovereenkomstig.
// Browser
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");Waarom breekt atob() bij niet-ASCII JWT-claims?
atob() geeft een Latin-1 string terug waarbij elk teken naar één byte wordt omgezet. Multi-byte UTF-8-reeksen (emoji, CJK-tekens, geaccentueerde letters buiten Latin-1) worden verspreid over meerdere tekens, wat verminkte uitvoer oplevert. De oplossing is om de binaire string eerst te converteren naar een Uint8Array, en die array vervolgens door te geven aan new TextDecoder("utf-8").decode(). De TextDecoder API hermonteert multi-byte reeksen correct. Dit probleem is gemakkelijk over het hoofd te zien tijdens de ontwikkeling omdat de meeste JWT-payloads alleen ASCII-gebruikers-ID's, tijdstempels en rolnamen bevatten — de fout verschijnt pas wanneer een claim een niet-ASCII weergavenaam of een gelokaliseerde string bevat. Gebruik altijd het TextDecoder-pad in nieuwe code, zelfs als je huidige payloads alleen ASCII zijn, omdat claims kunnen veranderen naarmate de applicatie evolueert.
// Gebroken: atob geeft Latin-1 terug, multi-byte tekens zijn verminkt
const broken = atob(base64); // "ð\x9F\x8E\x89" in plaats van de emoji
// Opgelost: converteer naar byte-array, dan TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);Kan ik een JWT-handtekening verifiëren in JavaScript?
Decoderen en verifiëren zijn verschillende bewerkingen. Decoderen leest alleen de payload, die niet versleuteld is. Verificatie controleert de handtekening aan de hand van een geheim (HMAC) of publieke sleutel (RSA/ECDSA). De jose-bibliotheek ondersteunt beide in de browser via de Web Crypto API en in Node.js. Het jsonwebtoken-pakket werkt alleen in Node.js. Vertrouw nooit op gedecodeerde claims zonder de handtekening server-side te verifiëren. Aan de clientzijde is het acceptabel om een JWT te decoderen om de weergavenaam of vervaltijd van de gebruiker te lezen, maar elke toegangscontrolebeslissing — controleren of een gebruiker een bepaalde rol of toestemming heeft — moet plaatsvinden in server-side code na verificatie. Een aanvaller die het JWT-formaat begrijpt, kan een token met willekeurige claims aanmaken en jouw client-side controle zal slagen.
import * as jose from "jose";
const secret = new TextEncoder().encode("your-256-bit-secret");
const { payload } = await jose.jwtVerify(token, secret);
console.log(payload.sub); // geverifieerde claimsHoe controleer ik of een JWT verlopen is in JavaScript?
Decodeer de payload en lees de exp-claim, die een Unix-tijdstempel in seconden is. Vergelijk het met de huidige tijd via Math.floor(Date.now() / 1000). Als de huidige tijd groter is dan exp, is het token verlopen. Onthoud: de exp-waarde is seconden sinds epoch, niet milliseconden, dus het delen van Date.now() door 1000 is vereist. Bouw in de praktijk een kleine klokafwijkingsbuffer in — controleren of het token binnen de volgende 30 seconden verloopt in plaats van strikt in het verleden voorkomt randgevallen waarbij het token nog technisch geldig is bij decodering maar verlopen is tegen de tijd dat de volgende downstream API-aanroep het verwerkt. Behandel ook het geval waarbij exp volledig ontbreekt, wat betekent dat het token nooit verloopt.
function isTokenExpired(token) {
const payload = JSON.parse(
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
const nowSeconds = Math.floor(Date.now() / 1000);
return payload.exp < nowSeconds;
}
console.log(isTokenExpired(myToken)); // true of falseHoe schrijf ik isomorfe JWT-decoderingscode die werkt in zowel Node.js als de browser?
Controleer op de aanwezigheid van globalThis.Buffer. Als het bestaat, bevindt je je in Node.js en kun je Buffer.from(segment, "base64url").toString("utf-8") gebruiken. Als het niet bestaat, bevind je je in een browser en moet je atob() gebruiken met de TextDecoder-aanpak. Omsluit deze controle in een enkele decodeBase64Url-functie en gebruik die overal. Dit is het meest relevant voor hulppakketten, design system-componenten en gedeelde code in een monorepo-pakket dat wordt geïmporteerd door zowel een Next.js server-component als een browser React-component. De omgevingsdetectie op één plek bewaren betekent dat je het maar op één plek hoeft bij te werken als de runtime verandert — bijvoorbeeld wanneer Deno volledige Buffer-ondersteuning toevoegt of een nieuwe edge-runtime een ander codepad vereist.
function decodeBase64Url(segment) {
if (typeof Buffer !== "undefined") {
return Buffer.from(segment, "base64url").toString("utf-8");
}
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}Gerelateerde Tools
Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.
Sophie is a full-stack developer focused on TypeScript across the entire stack — from React frontends to Express and Fastify backends. She has a particular interest in type-safe API design, runtime validation, and the patterns that make large JavaScript codebases stay manageable. She writes about TypeScript idioms, Node.js internals, and the ever-evolving JavaScript module ecosystem.