JWT Decoder JavaScript — atob(), TextDecoder & jose
Nutze das kostenlose JWT Decoder direkt im Browser – keine Installation erforderlich.
JWT Decoder online testen →Jeder Authentifizierungsflow, den ich gebaut habe, erreicht irgendwann denselben Punkt: Ein JWT sitzt in einem Cookie, einem Header oder einer OAuth-Callback-URL, und du musst lesen, was darin steckt. Ein JWT-Decoder in JavaScript benötigt kein npm-Paket. Der Header und der Payload des Tokens sind lediglich Base64url-kodiertes JSON, und sowohl der Browser als auch Node.js liefern alles mit, was zum Dekodieren benötigt wird. Dieser Leitfaden behandelt die vollständige JavaScript-TextDecoder-Pipeline für JWTs: das Aufteilen des Tokens, die Normalisierung von base64url zu Standard-Base64, atob() und TextDecoder für korrekte UTF-8-Verarbeitung, Node.js Buffer.from(), Signaturprüfung mit jose, und die häufigsten Fehler, über die Entwickler täglich stolpern. Für eine schnelle einmalige Inspektion kann auch der Online-JWT-Decoder verwendet werden. Alle Beispiele zielen auf ES2020+ und Node.js 18+ ab.
- ✓Teile das JWT beim "." auf — Index 0 ist der Header, Index 1 ist der Payload, Index 2 ist die Signatur.
- ✓atob() dekodiert Base64, gibt aber Latin-1 zurück, nicht UTF-8. Verwende TextDecoder oder Buffer.from() für Nicht-ASCII-Claims.
- ✓Buffer.from(segment, "base64url") verarbeitet base64url in Node.js nativ — kein manueller Zeichenaustausch erforderlich.
- ✓Dekodieren ist KEINE Verifikation. Vertraue Claims aus einem dekodieren JWT niemals ohne serverseitige Signaturprüfung.
- ✓Die jose-Bibliothek macht beides: Sie verifiziert HS256/RS256/ES256-Signaturen und gibt den dekodieren Payload in einem Aufruf zurück.
Was ist JWT-Dekodierung?
Ein JSON Web Token besteht aus drei Base64url-kodierten Segmenten, die durch Punkte getrennt sind. Das erste Segment ist der Header, das zweite ist der Payload (die Claims, die eigentlich von Interesse sind), und das dritte ist die kryptografische Signatur. Der Header ist ein kleines JSON-Objekt, das das Token selbst beschreibt. Sein wichtigstes Feld ist alg — der Signaturalgorithmus (z.B. HS256, RS256, ES256). Das typ Feld ist fast immer "JWT", und das optionale kid Feld identifiziert, welcher Schlüssel zum Signieren des Tokens verwendet wurde — entscheidend, wenn ein Identity-Provider Schlüssel rotiert und einen JWKS-Endpunkt mit mehreren öffentlichen Schlüsseln veröffentlicht.
Der Payload trägt die Claims. RFC 7519 definiert sieben registrierte Claim-Namen: sub (Subject — üblicherweise die Benutzer-ID), iss (Issuer — die URL des Auth-Servers), aud (Audience — die API, für die das Token bestimmt ist), iat (Ausstellungszeitstempel), exp (Ablaufzeitstempel), nbf (Nicht-vor-Zeitstempel) und jti (JWT-ID — zur Verhinderung von Replay-Angriffen). Alle Zeitstempel sind Unix-Epoch-Sekunden, keine Millisekunden. Das Signatur-Segment ist rohes Binärdaten — ein HMAC-Digest mit Schlüssel oder eine asymmetrische digitale Signatur. Es ist wie die anderen Segmente Base64url-kodiert, aber seine Bytes sind kein JSON und haben keine menschenlesbare Struktur.
In der Praxis dekodiert man JWTs in JavaScript aus drei häufigen Gründen. Erstens zum Debuggen: Man hat ein Token aus einem OAuth-Flow oder einer Testumgebung und möchte bestätigen, dass die Claims mit dem übereinstimmen, was der Auth-Server ausgegeben haben sollte. Zweitens zum Lesen von Benutzer-Claims für Anzeigezwecke auf der Client-Seite — der Name, die Avatar-URL oder das Rollen-Badge des eingeloggten Benutzers aus dem Token-Payload anzeigen, ohne einen zusätzlichen API-Aufruf. Drittens zur Ablaufprüfung vor einem Refresh-Versuch: Wenn exp innerhalb der nächsten 60 Sekunden liegt, wird ein stiller Refresh ausgelöst, bevor der nächste API-Aufruf erfolgt, anstatt auf eine 401-Antwort zu warten.
Das Dekodieren prüft nicht, ob das Token gültig oder manipuliert ist. Das ist eine separate Operation namens Verifikation, die das HMAC-Geheimnis oder den öffentlichen RSA/ECDSA-Schlüssel erfordert. Jeder kann ein JWT dekodieren. Nur der Inhaber des korrekten Schlüssels kann es verifizieren. Diese Unterscheidung bringt viele Entwickler durcheinander, besonders beim Aufbau von clientseitigen Auth-Flows, bei denen dekodierte Claims angezeigt, aber niemals für Autorisierungsentscheidungen ohne eine verifizierte Backend-Prüfung vertraut werden dürfen.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Header
{ "alg": "HS256" }
// Payload
{
"sub": "usr_921f",
"role": "admin",
"iat": 1711610000
}atob() + TextDecoder — Browser-natives JWT-Dekodieren
Die browser-native Pipeline zur Dekodierung eines JWTs umfasst vier Schritte. Erstens: Den Token-String beim "." aufteilen, um die drei Segmente zu erhalten. Zweitens: Das Base64url-Segment normalisieren, indem - durch + und _ durch / ersetzt wird, dann mit = auffüllen, bis die Länge ein Vielfaches von 4 ist. Drittens: atob() aufrufen, um das Base64 in einen Binärstring zu dekodieren. Viertens: Den Binärstring mit TextDecoder in korrektes UTF-8 umwandeln. Dieser letzte Schritt ist wichtig, weil atob() Latin-1 zurückgibt. Mehrbyte-Zeichen — Emojis, CJK-Text, Akzentzeichen jenseits des Latin-1-Bereichs — kommen ohne den JavaScript-TextDecoder-Schritt unleserlich heraus.
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 }Der Auffüll-Schritt wird leicht übersehen. JWTs entfernen nachgestellte = Zeichen aus ihren Base64url-Segmenten, weil die JWT-Spezifikation (RFC 7515) base64url ohne Padding definiert. Aber atob() in einigen Browser-Engines wirft einen InvalidCharacterError, wenn die Eingabelänge nicht durch 4 teilbar ist. Defensives Auffüllen mit padEnd() vermeidet diesen Grenzfall in allen Umgebungen. Hier ist eine wiederverwendbare Version, die sowohl Header als auch Payload in separate Objekte dekodiert:
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"Sobald du diese beiden Funktionen hast, lohnt es sich, sie in einem gemeinsamen Utility-Modul zu platzieren, anstatt die Logik über mehrere Dateien zu kopieren. Eine src/lib/jwt.ts oder utils/jwt-decode.ts Datei mit einer typisierten Rückgabeform macht die Absicht im gesamten Codebase explizit. In TypeScript kann man den Rückgabetyp als { header: JwtHeader; payload: JwtPayload } typisieren, wobei JwtHeader alg, typ und optionales kid enthält, und JwtPayload die in RFC 7519 registrierten Claims mit einer Index-Signatur für benutzerdefinierte Claims erweitert. Die Zentralisierung der Dekodierungslogik bedeutet, dass man bei einer späteren Ergänzung von Fehlerbehandlung (Abfangen fehlerhafter Segmente) oder Telemetrie (Protokollierung von Dekodierungsfehlern) nur eine Stelle aktualisieren muss.
TextDecoder-Schritt macht diese Pipeline sicher für Nicht-ASCII-Claims. Ohne ihn gibt atob() einen Latin-1-String zurück, bei dem Mehrbyte-UTF-8-Sequenzen über Zeichen aufgeteilt werden. Anstelle von Emojis oder CJK-Text erscheint unleserlicher Zeichensalat. Immer durch new TextDecoder("utf-8") nach atob() leiten.UTF-8-JWT-Claims mit Mehrbyte-Zeichen dekodieren
JWT-Payloads sind UTF-8-JSON, kodiert als base64url. Die meisten Payloads enthalten nur ASCII-Felder wie Benutzer-IDs und Zeitstempel, sodass Entwickler nie bemerken, dass atob() Latin-1 statt UTF-8 zurückgibt. Das Problem tritt in dem Moment auf, wenn ein Claim ein Emoji, japanische Zeichen, kyrillische Schrift oder einen Code-Point über U+00FF enthält. Das JavaScript-decode-UTF-8-Muster erfordert zunächst die Umwandlung des Binärstrings in ein Byte-Array und dann die Verarbeitung durch TextDecoder.
// Simuliert einen JWT-Payload mit Emoji und CJK-Zeichen
const payloadObj = {
sub: "usr_e821",
display_name: "田中太郎",
team: "Platform 🚀",
region: "ap-northeast-1"
};
// Kodieren: Objekt → 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(/=+$/, "");
// Dekodieren: base64url → base64 → binärer 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); // "田中太郎" — korrekt
console.log(result.team); // "Platform 🚀" — korrektIn älteren Codebasen gibt es ein Legacy-Fallback-Muster, das decodeURIComponent kombiniert mit einem Prozent-Kodierungs-Trick verwendet. Dieser JavaScript-decodeURIComponent-Ansatz funktioniert, weil er jedes Byte als Prozent-Hex-Paar neu kodiert, und dann decodeURIComponent die mehrbytigen UTF-8-Sequenzen wieder zusammensetzt:
function decodeBase64UrlLegacy(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
// Jeden Char in %XX-Hex umwandeln, dann setzt decodeURIComponent UTF-8 zusammen
const utf8 = decodeURIComponent(
binary.split("").map(c =>
"%" + c.charCodeAt(0).toString(16).padStart(2, "0")
).join("")
);
return utf8;
}
// Funktioniert für Nicht-ASCII-Claims ohne TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));decodeURIComponent(escape(atob(segment))) findet sich in älteren JWT-Utility-Snippets. Die escape()-Funktion ist veraltet und nicht standardisiert. Sie sollte durch den oben gezeigten TextDecoder-Ansatz ersetzt werden. Das JavaScript-unescape-Decoder-Muster hat dasselbe Problem: unescape() ist veraltet. Beide Funktionen könnten aus zukünftigen JavaScript-Engines entfernt werden.JWT-Dekodierungs-Pipeline — Schritt-Referenz
Jeder Schritt in der browser-nativen JWT-Dekodierungs-Pipeline, mit der verwendeten JavaScript-API und dem, was sie produziert:
Das Node.js-Äquivalent fasst die Schritte 2 bis 4 in einem einzigen Aufruf zusammen: Buffer.from(segment, "base64url").toString("utf-8"). Die "base64url" Kodierungsoption behandelt die Alphabet-Konvertierung und das Padding intern.
Buffer.from() — Der Node.js-String-Decoder für JWTs
Node.js bietet einen viel einfacheren Weg. Die Buffer Klasse akzeptiert eine "base64url" Kodierung direkt, sodass der manuelle Zeichenaustausch und das Padding entfallen. Dies ist der JavaScript-String-Decoder-Pfad für serverseitigen Code. Eine Zeile verwandelt ein JWT-Segment in einen UTF-8-String und verarbeitet Mehrbyte-Zeichen korrekt ohne zusätzliche Schritte.
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 }Dies ist der Ansatz, den ich in jedem Node.js-Projekt verwende. Er ist kürzer, schneller und verarbeitet UTF-8 von Haus aus korrekt. Kein TextDecoder erforderlich, kein Zeichenaustausch, keine Padding-Berechnungen. Die Buffer Klasse ist ein JavaScript-String-Decoder, der das Base64url-Alphabet nativ verarbeitet und damit eine ganze Klasse von Fehlern im Zusammenhang mit dem Zeichenaustausch eliminiert. Wenn der Code sowohl im Browser als auch in Node.js laufen muss, findet sich im FAQ-Bereich unten eine isomorphe Wrapper-Funktion, die die Umgebung zur Laufzeit erkennt.
Hier ist ein vollständigeres Beispiel, das zeigt, wie man häufige JWT-Claims extrahiert und Zeitstempel in lesbare Datumsangaben umwandelt — das Muster, das am häufigsten in Middleware und API-Route-Handlern verwendet wird:
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 produktiven Node.js-Diensten taucht das Buffer.from() Dekodierungsmuster an drei wiederkehrenden Stellen auf. Die erste ist Request-Logging-Middleware: Der eingehende Authorization Header wird dekodiert, um userId und org an jeden strukturierten Log-Eintrag anzuhängen, ohne einen zusätzlichen Netzwerk-Roundtrip zum Auth-Server. Die zweite ist Debugging: Dekodierte Token-Claims werden während der Entwicklung auf der Konsole ausgegeben, um zu bestätigen, dass die korrekten Scopes ausgegeben wurden, bevor Test-Assertions geschrieben werden. Die dritte ist das proaktive Token-Refresh in API-Gateways. Anstatt ein Token upstream weiterzuleiten und den nachgelagerten Dienst einen 401 zurückgeben zu lassen, wenn das Token mitten in einem Request abläuft, dekodiert das Gateway das Token am Rand, liest den exp Claim und löst einen Refresh aus, wenn der Ablauf innerhalb der nächsten 30 Sekunden liegt. Dies eliminiert eine Klasse von transienten Auth-Fehlern, die schwer zu reproduzieren und frustrierend zu debuggen sind.
"base64url"-Kodierung wurde in Node.js 15.7.0 hinzugefügt. Bei Node.js 14 oder früher als Fallback verwenden: Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") — funktioniert genauso, erfordert aber den manuellen Zeichenaustausch.JWT aus einer Datei und einer API-Antwort dekodieren
Zwei Szenarien treten ständig auf. Das erste ist das Lesen eines JWTs aus einer lokalen Datei: ein gespeichertes Token während der Entwicklung, eine Test-Fixture oder eine bei einem Vorfall gespeicherte Datei für die Post-Mortem-Analyse. Das zweite ist das Extrahieren eines JWTs aus einer HTTP-Antwort, typischerweise das access_token Feld in einem OAuth-Token-Antwort-Body oder einem Authorization Header. Beide benötigen Fehlerbehandlung, da fehlerhafte Tokens, abgeschnittene Dateien und Netzwerkfehler alltägliche Realitäten sind. Ein Token, das letzte Woche noch gültig war, könnte durch Copy-Paste nachgestellte Leerzeichen oder Zeilenumbrüche erhalten haben. Ein Antwort-Body könnte HTML statt JSON sein, wenn der Auth-Server eine Fehlerseite zurückgegeben hat.
JWT aus einer Datei lesen (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 aus einer API-Antwort extrahieren (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);
}
// Verwendung
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-Dekodierung über die Kommandozeile
Manchmal möchte man einfach einen Blick auf ein Token aus dem Terminal werfen, ohne ein Skript zu schreiben. Node.js ist auf den meisten Entwicklermaschinen verfügbar, sodass ein Einzeiler gut funktioniert. jq übernimmt die formatierte Ausgabe.
# JWT-Payload mit Node.js-Einzeiler dekodieren
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'))))"
# Ausgabe mit jq formatieren
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 .
# Sowohl Header als auch Payload dekodieren
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()));
});
"Wenn reines Bash ohne Node.js bevorzugt wird, das Segment durch base64 -d leiten, nachdem die Base64url-Zeichen mit tr korrigiert wurden:
# Reines Bash: JWT-Payload ohne Node.js dekodieren echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq . # macOS-Variante (base64 -D statt -d) echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .
Für eine schnelle visuelle Inspektion ohne Terminal das Token in den ToolDeck JWT Decoder einfügen, der eine Seite-an-Seite-Aufschlüsselung aller drei Segmente mit farblich kodierten Claim-Bezeichnungen und Ablaufstatus bietet.
jose — Verifikation und Dekodierung in einer Bibliothek
Für produktive Authentifizierungs-Middleware wird Signaturprüfung benötigt, nicht nur Dekodierung. Die jose Bibliothek ist die beste Option. Sie funktioniert sowohl in Node.js als auch in Browsern (über die Web Crypto API), unterstützt HS256, RS256, ES256, EdDSA und JWE (verschlüsselte Tokens) und hat keine nativen Abhängigkeiten. Installation mit 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";
// Öffentlichen Schlüsselsatz vom Identity-Provider abrufen
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, usw. sind jetzt verifiziert
req.userId = payload.sub;
} catch (err) {
return res.status(401).json({ error: "Invalid token" });
}Bei der Entscheidung zwischen jose und dem älteren jsonwebtoken Paket ist der Hauptunterschied der Laufzeit-Umfang. jsonwebtoken ist nur für Node.js — es setzt auf das crypto Built-in auf und lässt sich nicht für den Browser bündeln. jose ist vollständig isomorph: Es verwendet die Web Crypto API, die in allen modernen Browsern, Node.js 16+, Deno, Bun und Cloudflare Workers verfügbar ist. Wenn die Auth-Logik in einer Next.js-Middleware-Datei (die in der Edge Runtime läuft), in einem Cloudflare Worker oder in einem gemeinsam genutzten Utility liegt, das von Server- und Client-Code importiert wird, ist jose die richtige Wahl, da es keine nativen Abhängigkeiten hat und ohne Build-Schritt installiert. jsonwebtoken bleibt sinnvoll für reine Node.js-Serveranwendungen, bei denen das breitere Ökosystem an Signing-Helfern benötigt wird und kein Betrieb in einer Edge-Umgebung geplant ist. In einem Greenfield-Projekt im Jahr 2026 standardmäßig jose verwenden, sofern kein spezifischer Grund für die ältere API besteht.
Wenn nur Dekodierung ohne Verifikation benötigt wird, bietet jose jose.decodeJwt(token), das den Payload zurückgibt, und jose.decodeProtectedHeader(token) für den Header. Das sind Komfortfunktionen, die die Base64url-Dekodierung intern erledigen. Aber der eigentliche Grund für jose ist, dass man selten ohne Verifikation dekodieren sollte. Auf der Client-Seite, wenn nur der eigene Anzeigename oder Avatar-URL aus den Token-Claims angezeigt werden soll, ist Decode-only in Ordnung. Auf der Server-Seite immer verifizieren. Ich habe Produktionssysteme gesehen, die JWT-Claims für Zugriffskontrollentscheidungen dekodiert haben, ohne die Signatur zu prüfen — und das ist eine offene Tür für jeden Angreifer, der das JWT-Format versteht.
import * as jose from "jose";
// Nur dekodieren: kein Secret nötig, keine Verifikation
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"
// Ablauf ohne Verifikation prüfen (clientseitige Anzeige)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
console.log("Token abgelaufen — zur Anmeldung weiterleiten");
}Terminal-Ausgabe mit Syntax-Hervorhebung
Beim Debuggen von JWT-Tokens in einem Node.js-CLI-Tool oder während eines Vorfalls macht farbcodierte Ausgabe einen echten Unterschied. Die chalk Bibliothek in Kombination mit JSON.stringify erledigt die Aufgabe. Installation mit 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)));
// Ablaufstatus hervorheben
if (payload.exp) {
const expiresAt = new Date(payload.exp * 1000);
const isExpired = expiresAt < new Date();
console.log(
chalk.bold("\nAblauf:"),
isExpired
? chalk.red(`ABGELAUFEN am ${expiresAt.toISOString()}`)
: chalk.green(`Gültig bis ${expiresAt.toISOString()}`)
);
}
console.log(chalk.dim("\nSignatur: " + segments[2].substring(0, 20) + "..."));
}
printJwt(process.argv[2]);
// Ausführen: node jwt-debug.mjs "eyJhbGci..."JWTs aus großen Log-Dateien verarbeiten
Moderne API-Infrastruktur gibt strukturierte Zugriffsprotokolle im NDJSON-Format aus — ein JSON-Objekt pro Zeile, wobei jede Zeile den Request-Pfad, den Antwortstatus, die Latenz und den dekodierten oder rohen Authorization Header enthält. Bei einem stark ausgelasteten Dienst wachsen diese Dateien schnell: Ein Gateway, das 10.000 Anfragen pro Minute verarbeitet, produziert über 14 Millionen Log-Einträge pro Tag. Sicherheits- und Compliance-Anwendungsfälle erfordern regelmäßig das nachträgliche Scannen dieser Dateien — Identifizierung jeder Anfrage eines kompromittierten Service-Accounts (Post-Incident-Analyse), Bestätigung, dass die Tokens eines bestimmten Benutzers vor einem Datenzugriffsfenster abgelaufen sind (Compliance-Audit), oder Extraktion aller Subjects, die während eines Wartungsfensters auf einen sensiblen Endpunkt zugegriffen haben. Da eine einzelne Log-Datei mehrere Gigabyte überschreiten kann, ist das Laden mit readFileSync nicht praktikabel. Node.js-readline-Streams verarbeiten die Datei zeilenweise mit konstantem Speicheraufwand, was das Scannen beliebig großer Logs auf einem Standard-Entwicklerlaptop ermöglicht.
Das Problem "Datei zu groß für den Arbeitsspeicher" tritt bei einzelnen JWTs nicht auf, da ein einzelnes Token selten mehr als einige Kilobytes umfasst. Das Szenario, das vorkommt, ist das Scannen eines großen Zugriffsprotokolls oder Audit-Trails nach JWT-Tokens, das Dekodieren jedes einzelnen und das Extrahieren bestimmter Claims. Node.js-Streams verarbeiten dies, ohne die gesamte Datei zu 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 {
// Fehlerhafte Zeilen überspringen
}
}
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 {
// Kein gültiges JWT
}
}
}
console.log(`Found ${subjects.size} unique subjects:`);
for (const sub of subjects) console.log(` ${sub}`);
}
extractUniqueSubjects("./logs/gateway-2026-03.log");readFileSync belegt den Arbeitsspeicher und löst GC-Pausen aus. Der readline-Ansatz verarbeitet jeweils eine Zeile mit konstantem Speicherverbrauch.Häufige Fehler
Problem: atob() gibt einen Latin-1-String zurück. Mehrbyte-UTF-8-Zeichen (Emojis, CJK, Akzentzeichen) werden über Zeichen aufgeteilt und kommen unleserlich heraus.
Lösung: Die atob()-Ausgabe in ein Uint8Array umwandeln und durch new TextDecoder('utf-8') leiten.
// Bricht bei Nicht-ASCII-Payload-Claims
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name erscheint als "ç°ä¸å¤ªé\x83\x8E" statt "田中太郎"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 zeigt korrekt "田中太郎"Problem: atob() wirft "InvalidCharacterError", weil base64url - und _ statt + und / verwendet.
Lösung: - durch + und _ durch / ersetzen, bevor atob() aufgerufen wird. Node.js Buffer.from() mit 'base64url' erledigt das automatisch.
// Wirft: 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); // funktioniert jetztProblem: Jeder kann ein JWT mit beliebigem Payload erstellen. Das Dekodieren liest nur die Daten — es beweist nicht, dass das Token von deinem Auth-Server ausgestellt wurde.
Lösung: Auf der Server-Seite immer die Signatur mit jose.jwtVerify() oder jsonwebtoken.verify() verifizieren. Nur-Dekodierung ist für clientseitige Anzeige von Benutzer-Claims akzeptabel.
// GEFÄHRLICH: dekodiert aber nicht verifiziert
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
grantAdminAccess(); // Angreifer können das fälschen
}import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
grantAdminAccess(); // sicher — Signatur ist verifiziert
}Problem: JWT exp ist in Sekunden seit Epoch, aber Date.now() gibt Millisekunden zurück. Der Vergleich sagt immer, dass das Token gültig ist, weil der Millisekunden-Zeitstempel 1000-mal größer ist.
Lösung: Date.now() durch 1000 teilen und das Ergebnis abrunden, bevor mit exp verglichen wird.
// Fehler: Date.now() ist Millisekunden, exp ist Sekunden
if (payload.exp > Date.now()) {
console.log("Token ist gültig"); // immer wahr — falsch!
}const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
console.log("Token ist gültig"); // korrekter Vergleich
}JWT-Dekodierungsmethoden — Kurzvergleich
atob() + TextDecoder für browserseitiges Dekodieren verwenden, wenn Claims nur dem Benutzer angezeigt werden müssen.Buffer.from() in Node.js-Skripten und CLI-Tools verwenden. Auf jose zurückgreifen, sobald eine Signaturprüfung benötigt wird — also bei jeder serverseitigen Auth-Middleware. Das jwt-decode Paket ist eine leichtgewichtige Alternative, wenn eine Ein-Funktions-API für Decode-only im Browser gewünscht wird. Für schnelle visuelle Inspektion ohne Code zu schreiben, das Token in das JWT-Decoder-Tool einfügen.
Häufig gestellte Fragen
Wie dekodiere ich ein JWT-Token in JavaScript ohne eine Bibliothek?
Teile das Token beim "." auf, nimm das zweite Segment (den Payload), normalisiere die Base64url-Kodierung, indem du - durch + und _ durch / ersetzt, fülle mit =-Zeichen auf, und rufe dann atob() gefolgt von TextDecoder auf, um den UTF-8-JSON-String zu erhalten. Leite das Ergebnis durch JSON.parse() und du erhältst das Claims-Objekt. Kein npm-Paket erforderlich. Dieser Ansatz funktioniert in allen modernen Browsern und in Node.js 18+. Wenn du auch den Header lesen möchtest, wende dieselben Dekodierungsschritte auf das erste Segment an. Beachte, dass du so die Rohdaten ohne jegliche Signaturprüfung erhältst — behandle das Ergebnis als reine Anzeigedaten, solange du die Signatur nicht serverseitig verifizierst.
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" }Was ist der Unterschied zwischen atob() und Buffer.from() beim JWT-Dekodieren?
atob() ist eine Browser-API, die Standard-Base64 in einen Latin-1-Binärstring dekodiert. Sie versteht Base64url-Kodierung nicht direkt, daher musst du zuerst - und _ ersetzen. Buffer.from(segment, "base64url") ist eine Node.js-API, die das Base64url-Alphabet nativ verarbeitet und einen Buffer zurückgibt, auf dem du .toString("utf-8") aufrufen kannst. Verwende atob() im Browser, Buffer.from() in Node.js. Eine dritte Option — die langsamer, aber historisch verbreitet ist — ist der decodeURIComponent-Prozent-Kodierungs-Trick, aber dieses Muster verwendet die veraltete escape()-Funktion in einigen älteren Code-Fragmenten und sollte in neuem Code vermieden werden. Für isomorphen Code, der in beiden Umgebungen läuft, prüfe typeof Buffer !== "undefined" und verzweige entsprechend.
// Browser
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");Warum bricht atob() bei Nicht-ASCII-JWT-Claims ab?
atob() gibt einen Latin-1-String zurück, bei dem jedes Zeichen einem einzelnen Byte entspricht. Mehrbyte-UTF-8-Sequenzen (Emoji, CJK-Zeichen, Buchstaben mit Akzenten jenseits von Latin-1) werden über mehrere Zeichen aufgeteilt und erzeugen unlesbaren Output. Die Lösung besteht darin, den binären String zuerst in ein Uint8Array umzuwandeln und dieses Array dann an new TextDecoder("utf-8").decode() zu übergeben. Die TextDecoder-API setzt Mehrbyte-Sequenzen korrekt zusammen. Dieses Problem ist in der Entwicklung leicht zu übersehen, da die meisten JWT-Payloads nur ASCII-Benutzer-IDs, Zeitstempel und Rollennamen enthalten — der Fehler tritt erst auf, wenn ein Claim einen Nicht-ASCII-Anzeigenamen oder einen lokalisierten String enthält. Verwende in neuem Code immer den TextDecoder-Pfad, auch wenn deine aktuellen Payloads nur ASCII enthalten, da sich Claims mit der Weiterentwicklung der Anwendung ändern können.
// Fehlerhaft: atob gibt Latin-1 zurück, Mehrbyte-Zeichen sind unleserlich
const broken = atob(base64); // "ð\x9F\x8E\x89" statt des Emojis
// Korrekt: in Byte-Array umwandeln, dann TextDecoder verwenden
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);Kann ich eine JWT-Signatur in JavaScript verifizieren?
Dekodieren und Verifizieren sind verschiedene Operationen. Das Dekodieren liest lediglich den Payload, der nicht verschlüsselt ist. Die Verifizierung prüft die Signatur anhand eines Geheimnisses (HMAC) oder eines öffentlichen Schlüssels (RSA/ECDSA). Die jose-Bibliothek unterstützt beides im Browser über die Web Crypto API und in Node.js. Das jsonwebtoken-Paket funktioniert nur in Node.js. Vertraue dekodierte Claims niemals ohne serverseitige Signaturprüfung. Auf der Clientseite ist es akzeptabel, ein JWT zu dekodieren, um den Anzeigenamen oder die Ablaufzeit des Benutzers zu lesen, aber jede Zugriffskontrollentscheidung — die Prüfung, ob ein Benutzer eine bestimmte Rolle oder Berechtigung hat — muss im serverseitigen Code nach der Verifizierung erfolgen. Ein Angreifer, der das JWT-Format versteht, kann ein Token mit beliebigen Claims erstellen, und deine clientseitige Prüfung wird erfolgreich sein.
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); // verified claimsWie prüfe ich in JavaScript, ob ein JWT abgelaufen ist?
Dekodiere den Payload und lies den exp-Claim, der ein Unix-Zeitstempel in Sekunden ist. Vergleiche ihn mit der aktuellen Zeit mittels Math.floor(Date.now() / 1000). Wenn die aktuelle Zeit größer als exp ist, ist das Token abgelaufen. Beachte: Der exp-Wert ist Sekunden seit dem Epoch, nicht Millisekunden — daher ist die Division von Date.now() durch 1000 erforderlich. Baue in der Praxis einen kleinen Puffer für Zeitabweichungen ein — prüfe, ob das Token innerhalb der nächsten 30 Sekunden abläuft, anstatt nur strikt in der Vergangenheit zu prüfen. Das verhindert Grenzfälle, bei denen das Token beim Dekodieren noch gültig ist, aber bis zum nächsten nachgelagerten API-Aufruf abläuft. Behandle auch den Fall, dass exp vollständig fehlt, was bedeutet, dass das Token nie abläuft.
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 or falseWie schreibe ich isomorphen JWT-Dekodiercode, der sowohl in Node.js als auch im Browser funktioniert?
Prüfe die Existenz von globalThis.Buffer. Wenn er existiert, befindest du dich in Node.js und kannst Buffer.from(segment, "base64url").toString("utf-8") verwenden. Wenn nicht, befindest du dich in einem Browser und solltest atob() mit dem TextDecoder-Ansatz verwenden. Verpacke diese Prüfung in eine einzelne decodeBase64Url-Funktion und verwende sie überall. Dies ist besonders wichtig für Utility-Pakete, Design-System-Komponenten und gemeinsam genutzten Code in einem Monorepo-Paket, der sowohl von einer Next.js-Server-Komponente als auch von einer Browser-React-Komponente importiert wird. Wenn die Umgebungserkennung an einer Stelle bleibt, muss sie nur an einem Ort aktualisiert werden, wenn sich die Laufzeitumgebung ändert — zum Beispiel wenn Deno vollständige Buffer-Unterstützung hinzufügt oder eine neue Edge-Laufzeitumgebung einen anderen Code-Pfad erfordert.
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);
}Verwandte 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.