JWT Decoder JavaScript — atob(), TextDecoder i jose
Użyj darmowego Dekoder JWT bezpośrednio w przeglądarce — bez instalacji.
Wypróbuj Dekoder JWT online →Każdy przepływ uwierzytelniania, który budowałem, dociera w końcu do tego samego punktu: masz JWT zapisany w ciasteczku, nagłówku lub URL-u callbacku OAuth i musisz odczytać, co jest w środku. Dekoder JWT w JavaScript nie wymaga żadnego pakietu npm. Nagłówek i ładunek tokenu to po prostu JSON zakodowany w Base64url, a zarówno przeglądarka, jak i Node.js mają wszystko, czego potrzeba do ich zdekodowania. Ten przewodnik omawia pełny potok dekodera tekstu JavaScript dla JWT: podział tokenu, normalizację base64url do standardowego Base64, atob() i TextDecoder do prawidłowej obsługi UTF-8, Node.js Buffer.from(), weryfikację podpisu za pomocą jose, oraz typowe błędy, na które codziennie napotykają programiści. Aby szybko sprawdzić token jednorazowo, wypróbuj internetowy dekoder JWT zamiast tego. Wszystkie przykłady dotyczą ES2020+ i Node.js 18+.
- ✓Podziel JWT po "." — indeks 0 to nagłówek, indeks 1 to ładunek, indeks 2 to podpis.
- ✓atob() dekoduje Base64, ale zwraca Latin-1, nie UTF-8. Użyj TextDecoder lub Buffer.from() dla claimów spoza ASCII.
- ✓Buffer.from(segment, "base64url") obsługuje base64url natywnie w Node.js — nie potrzeba ręcznego zastępowania znaków.
- ✓Dekodowanie NIE jest weryfikacją. Nigdy nie ufaj claimom ze zdekodowanego JWT bez sprawdzenia podpisu po stronie serwera.
- ✓Biblioteka jose robi obie rzeczy: weryfikuje podpisy HS256/RS256/ES256 i zwraca zdekodowany ładunek w jednym wywołaniu.
Czym jest dekodowanie JWT?
JSON Web Token to trzy segmenty zakodowane w Base64url, rozdzielone kropkami. Pierwszy segment to nagłówek, drugi to ładunek (claime, które cię interesują), a trzeci to kryptograficzny podpis. Nagłówek to mały obiekt JSON opisujący sam token. Jego najważniejszym polem jest alg — algorytm podpisywania (np. HS256, RS256, ES256). Pole typ to prawie zawsze "JWT", a opcjonalne pole kid identyfikuje klucz użyty do podpisania tokenu — kluczowe, gdy dostawca tożsamości rotuje klucze i publikuje punkt końcowy JWKS z wieloma kluczami publicznymi.
Ładunek zawiera claime. RFC 7519 definiuje siedem zarejestrowanych nazw claimów: sub (subject — zazwyczaj identyfikator użytkownika), iss (issuer — URL serwera uwierzytelniania), aud (audience — API, dla którego przeznaczony jest token), iat (znacznik czasu wystawienia), exp (znacznik czasu wygaśnięcia), nbf (znacznik czasu „nie wcześniej niż") oraz jti (JWT ID — używany do zapobiegania atakom powtórzeniowym). Wszystkie znaczniki czasu są sekundami epoki Unix, nie milisekundami. Segment podpisu to surowe dane binarne — skrót HMAC z kluczem lub asymetryczny podpis cyfrowy. Jest zakodowany w Base64url jak pozostałe segmenty, ale jego bajty nie są JSON-em i nie mają struktury czytelnej dla człowieka.
W praktyce dekodujesz JWT w JavaScript z trzech powodów. Po pierwsze, debugowanie: masz token z przepływu OAuth lub środowiska testowego i chcesz potwierdzić, że claime zgadzają się z tym, co serwer uwierzytelniania powinien był wydać. Po drugie, odczyt claimów użytkownika do wyświetlenia po stronie klienta — pokazanie zalogowanemu użytkownikowi jego imienia, URL-a avatara lub odznaki roli z ładunku tokenu bez dodatkowego wywołania API. Po trzecie, sprawdzenie wygaśnięcia przed próbą odświeżenia: jeśli exp mieści się w ciągu następnych 60 sekund, wywołaj ciche odświeżenie przed kolejnym wywołaniem API zamiast czekać na odpowiedź 401.
Dekodowanie nie sprawdza, czy token jest ważny lub nie został zmodyfikowany. To odrębna operacja zwana weryfikacją, która wymaga sekretu HMAC lub klucza publicznego RSA/ECDSA. Każdy może zdekodować JWT. Tylko posiadacz właściwego klucza może go zweryfikować. To rozróżnienie myli wielu programistów, szczególnie przy budowaniu przepływów auth po stronie klienta, gdzie zdekodowane claime są wyświetlane, ale nigdy nie mogą być traktowane jako wiarygodne przy podejmowaniu decyzji autoryzacyjnych bez zweryfikowanego sprawdzenia po stronie backendu.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Nagłówek
{ "alg": "HS256" }
// Ładunek
{
"sub": "usr_921f",
"role": "admin",
"iat": 1711610000
}atob() + TextDecoder — natywne dekodowanie JWT w przeglądarce
Natywny potok przeglądarki do dekodowania JWT składa się z czterech kroków. Po pierwsze, podziel ciąg tokenu po "." aby uzyskać trzy segmenty. Po drugie, znormalizuj segment base64url zastępując - na + i _ na /, a następnie uzupełniając znakami = do wielokrotności 4. Po trzecie, wywołaj atob() aby zdekodować Base64 do ciągu binarnego. Po czwarte, przekonwertuj ciąg binarny na właściwy UTF-8 używając TextDecoder. Ten ostatni krok jest ważny, ponieważ atob() zwraca Latin-1. Znaki wielobajtowe — emoji, tekst CJK, znaki akcentowane spoza zakresu Latin-1 — wychodzą uszkodzone bez kroku dekodera tekstu JavaScript.
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 }Krok uzupełniania łatwo pominąć. JWT pozbawia końcowe znaki = ze swoich segmentów Base64url, ponieważ specyfikacja JWT (RFC 7515) definiuje base64url bez wypełnienia. Jednak atob() w niektórych silnikach przeglądarek rzuca InvalidCharacterError jeśli długość wejścia nie jest podzielna przez 4. Defensywne uzupełnianie za pomocą padEnd() pozwala uniknąć tego przypadku brzegowego we wszystkich środowiskach. Oto wersja wielokrotnego użytku, która dekoduje zarówno nagłówek, jak i ładunek do osobnych obiektów:
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"Po uzyskaniu tych dwóch funkcji warto umieścić je we współdzielonym module narzędziowym zamiast kopiować logikę do wielu plików. Plik src/lib/jwt.ts lub utils/jwt-decode.ts z typowanym kształtem zwracanego obiektu sprawia, że intencja jest jasna w całej bazie kodu. W TypeScript możesz wpisać zwracany typ jako { header: JwtHeader; payload: JwtPayload } gdzie JwtHeader zawiera alg, typ i opcjonalne kid, a JwtPayload rozszerza zarejestrowane claime RFC 7519 o sygnaturę indeksu dla claimów niestandardowych. Centralizacja logiki dekodowania oznacza, że gdy później chcesz dodać obsługę błędów (przechwytywanie uszkodzonych segmentów) lub telemetrię (logowanie błędów dekodowania), masz tylko jedno miejsce do aktualizacji.
TextDecoder sprawia, że ten potok jest bezpieczny dla claimów spoza ASCII. Bez niego atob() zwraca ciąg Latin-1, gdzie wielobajtowe sekwencje UTF-8 są rozdzielone na wiele znaków. Zamiast emoji lub tekstu CJK zobaczysz śmieci. Zawsze przepuszczaj przez new TextDecoder("utf-8") po atob().Dekodowanie claimów JWT w UTF-8 ze znakami wielobajtowymi
Ładunki JWT to JSON w UTF-8 zakodowany jako base64url. Większość ładunków zawiera pola wyłącznie ASCII, jak identyfikatory użytkowników i znaczniki czasu, więc programiści nigdy nie zauważają, że atob() zwraca Latin-1 zamiast UTF-8. Problem ujawnia się w momencie, gdy claim zawiera emoji, znaki japońskie, cyrylicę lub dowolny punkt kodowy powyżej U+00FF. Wzorzec dekodowania UTF-8 w JavaScript wymaga najpierw konwersji ciągu binarnego do tablicy bajtów, a następnie przepuszczenia jej przez TextDecoder.
// Symulacja ładunku JWT z emoji i znakami CJK
const payloadObj = {
sub: "usr_e821",
display_name: "田中太郎",
team: "Platform 🚀",
region: "ap-northeast-1"
};
// Kodowanie: obiekt → JSON → bajty UTF-8 → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// Dekodowanie: base64url → base64 → ciąg binarny → bajty → ciąg UTF-8
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); // "田中太郎" — poprawnie
console.log(result.team); // "Platform 🚀" — poprawnieIstnieje starszy wzorzec zastępczy, który można spotkać w starszych bazach kodu, używający decodeURIComponent w połączeniu ze sztuczką percent-encodingu. To podejście decodeURIComponent w JavaScript działa, ponieważ ponownie koduje każdy bajt jako parę procent-szesnastkową, a następnie decodeURIComponent składa z powrotem wielobajtowe sekwencje UTF-8:
function decodeBase64UrlLegacy(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
// Konwertuj każdy znak na %XX szesnastkowe, następnie decodeURIComponent składa UTF-8
const utf8 = decodeURIComponent(
binary.split("").map(c =>
"%" + c.charCodeAt(0).toString(16).padStart(2, "0")
).join("")
);
return utf8;
}
// Działa dla claimów spoza ASCII bez TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));decodeURIComponent(escape(atob(segment))) w starszych fragmentach narzędzi JWT. Funkcja escape() jest przestarzała i niestandardowa. Zastąp ją podejściem z TextDecoder pokazanym powyżej. Wzorzec dekodera unescape w JavaScript ma ten sam problem: unescape() jest przestarzałe. Obie funkcje mogą zostać usunięte z przyszłych silników JavaScript.Potok dekodowania JWT — opis kroków
Każdy krok natywnego potoku dekodowania JWT w przeglądarce, z użytym API JavaScript i tym, co produkuje:
Odpowiednik Node.js sprowadza kroki 2–4 do jednego wywołania: Buffer.from(segment, "base64url").toString("utf-8"). Opcja kodowania "base64url" obsługuje konwersję alfabetu i uzupełnianie wewnętrznie.
Buffer.from() — dekoder ciągów Node.js dla JWT
Node.js ma znacznie prostszą ścieżkę. Klasa Buffer akceptuje bezpośrednio kodowanie "base64url", więc pomijasz ręczne zastępowanie znaków i uzupełnianie. To jest ścieżka dekodera ciągów JavaScript dla kodu serwerowego. Jedna linia zamienia segment JWT na ciąg UTF-8 i poprawnie obsługuje znaki wielobajtowe bez żadnych dodatkowych kroków.
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 }To jest podejście, po które sięgam w każdym projekcie Node.js. Jest krótsze, szybsze i już poprawnie obsługuje UTF-8. Nie potrzeba TextDecoder, żadnego zastępowania znaków, żadnej matematyki uzupełniania. Klasa Buffer to dekoder ciągów JavaScript obsługujący alfabet base64url natywnie, co eliminuje całą klasę błędów związanych z zastępowaniem znaków. Jeśli twój kod musi działać zarówno w przeglądarce, jak i w Node.js, sprawdź sekcję FAQ na dole po izomorficzną funkcję opakowującą, która wykrywa środowisko w czasie wykonania.
Oto bardziej kompletny przykład pokazujący, jak wyodrębniać typowe claime JWT i konwertować znaczniki czasu na czytelne daty — wzorzec, który będziesz najczęściej stosować w oprogramowaniu pośredniczącym i handlerach tras API:
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"]
// }W produkcyjnych serwisach Node.js wzorzec dekodowania Buffer.from() pojawia się w trzech powtarzających się miejscach. Pierwsze to oprogramowanie pośredniczące logowania żądań: dekodujesz przychodzący nagłówek Authorization aby dołączyć userId i org do każdego wpisu w ustrukturyzowanym logu bez dodatkowej podróży sieciowej do serwera uwierzytelniania. Drugie to debugowanie: wypisujesz zdekodowane claime tokenu do konsoli podczas programowania, aby potwierdzić, że przed napisaniem asercji testowych wydano właściwe zakresy. Trzecie to proaktywne odświeżanie tokenu w bramkach API. Zamiast przekazywać token upstream i czekać, aż usługa downstream zwróci 401 po wygaśnięciu tokenu w trakcie żądania, bramka dekoduje token na brzegu, odczytuje claim exp i wywołuje odświeżenie, jeśli wygaśnięcie nastąpi w ciągu następnych 30 sekund. Eliminuje to klasę przejściowych błędów uwierzytelniania, które są trudne do odtworzenia i frustrujące w debugowaniu.
"base64url" zostało dodane w Node.js 15.7.0. Jeśli jesteś ograniczony do Node.js 14 lub wcześniejszego, wróć do Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") które działa tak samo, ale wymaga ręcznej zamiany znaków.Dekodowanie JWT z pliku i odpowiedzi API
Dwa scenariusze pojawiają się nieustannie. Pierwszy to odczyt JWT z lokalnego pliku: zapisany token podczas programowania, fixture testowy lub plik zrzucony podczas incydentu do analizy post-mortem. Drugi to wyodrębnianie JWT z odpowiedzi HTTP, zazwyczaj pola access_token w treści odpowiedzi tokenu OAuth lub nagłówka Authorization. Oba wymagają obsługi błędów, ponieważ uszkodzone tokeny, obcięte pliki i błędy sieciowe to codzienność. Token, który był ważny w ubiegłym tygodniu, może mieć końcowe białe znaki lub znaki nowej linii z kopiowania i wklejania. Treść odpowiedzi może być HTML zamiast JSON, jeśli serwer uwierzytelniania zwrócił stronę błędu.
Odczyt JWT z pliku (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);
}Wyodrębnianie JWT z odpowiedzi API (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);
}
// Użycie
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);
}Dekodowanie JWT z linii poleceń
Czasem po prostu chcesz zerknąć na token z terminala bez pisania skryptu. Node.js jest dostępny na większości maszyn programistycznych, więc oneliner działa dobrze. jq zajmuje się ładnym formatowaniem.
# Dekodowanie ładunku JWT za pomocą onelinera Node.js
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'))))"
# Przekazanie do jq dla ładnego wyjścia
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 .
# Dekodowanie zarówno nagłówka, jak i ładunku
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()));
});
"Jeśli wolisz czyste bash bez Node.js, przekaż segment przez base64 -d po naprawieniu znaków base64url za pomocą tr:
# Czyste bash: dekodowanie ładunku JWT bez Node.js echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq . # Wariant macOS (base64 -D zamiast -d) echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .
Do szybkiej inspekcji wizualnej bez żadnego terminala, wklej swój token do dekodera JWT ToolDeck aby zobaczyć wszystkie trzy segmenty z kolorowo oznaczonymi etykietami claimów i statusem wygaśnięcia.
jose — weryfikacja i dekodowanie w jednej bibliotece
W przypadku produkcyjnego oprogramowania pośredniczącego uwierzytelniania potrzebujesz weryfikacji podpisu, a nie tylko dekodowania. Biblioteka jose jest najlepszą opcją. Działa zarówno w Node.js, jak i w przeglądarkach (za pomocą Web Crypto API), obsługuje HS256, RS256, ES256, EdDSA i JWE (zaszyfrowane tokeny) oraz nie ma żadnych natywnych zależności. Zainstaluj za pomocą 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";
// Pobierz zestaw kluczy publicznych od dostawcy tożsamości
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 itd. są teraz zweryfikowane
req.userId = payload.sub;
} catch (err) {
return res.status(401).json({ error: "Invalid token" });
}Przy wyborze między jose a starszym pakietem jsonwebtoken kluczowa różnica to zakres środowiska. jsonwebtoken jest wyłącznie dla Node.js — opiera się na wbudowanym module crypto i nie można go spakować dla przeglądarki. jose jest w pełni izomorficzne: używa Web Crypto API, które jest dostępne we wszystkich nowoczesnych przeglądarkach, Node.js 16+, Deno, Bun i Cloudflare Workers. Jeśli twoja logika auth znajduje się w pliku middleware Next.js (który działa w Edge Runtime), w Cloudflare Worker lub we współdzielonym narzędziu importowanym zarówno przez kod serwera, jak i klienta, jose jest właściwym wyborem, ponieważ nie ma żadnych natywnych zależności i instaluje się bez kroku kompilacji. jsonwebtoken pozostaje rozsądny dla czystych aplikacji serwerowych Node.js, gdzie potrzebujesz jego szerszego ekosystemu pomocników do podpisywania i nie planujesz uruchamiać kodu w środowisku edge. W nowym projekcie w 2026 roku, domyślnie wybieraj jose chyba że masz konkretny powód, aby preferować starsze API.
Jeśli potrzebujesz tylko dekodowania bez weryfikacji, jose udostępnia jose.decodeJwt(token) zwracające ładunek oraz jose.decodeProtectedHeader(token) dla nagłówka. Są to funkcje wygodne, które wykonują dekodowanie Base64url wewnętrznie. Ale cały powód, dla którego sięga się po jose, to fakt, że rzadko kiedy należy dekodować bez jednoczesnej weryfikacji. Jeśli jesteś po stronie klienta i tylko chcesz pokazać użytkownikowi jego własną nazwę wyświetlaną lub URL avatara z claimów tokenu, dekodowanie bez weryfikacji jest akceptowalne. Po stronie serwera zawsze weryfikuj. Widziałem systemy produkcyjne, które dekodowały claime JWT do podejmowania decyzji kontroli dostępu bez sprawdzenia podpisu, i to jest otwarta droga dla każdego atakującego, który rozumie format JWT.
import * as jose from "jose";
// Tylko dekodowanie: nie potrzeba sekretu, bez weryfikacji
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"
// Sprawdź wygaśnięcie bez weryfikacji (wyświetlanie po stronie klienta)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
console.log("Token has expired — redirect to login");
}Wyjście terminala z podświetlaniem składni
Podczas debugowania tokenów JWT w narzędziu CLI Node.js lub w trakcie incydentu, kolorowane wyjście robi prawdziwą różnicę. Biblioteka chalk w połączeniu z JSON.stringify spełnia swoje zadanie. Zainstaluj za pomocą 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)));
// Podświetlenie statusu wygaśnięcia
if (payload.exp) {
const expiresAt = new Date(payload.exp * 1000);
const isExpired = expiresAt < new Date();
console.log(
chalk.bold("\nExpires:"),
isExpired
? chalk.red(`EXPIRED at ${expiresAt.toISOString()}`)
: chalk.green(`Valid until ${expiresAt.toISOString()}`)
);
}
console.log(chalk.dim("\nSignature: " + segments[2].substring(0, 20) + "..."));
}
printJwt(process.argv[2]);
// Uruchom: node jwt-debug.mjs "eyJhbGci..."Przetwarzanie JWT z dużych plików logów
Nowoczesna infrastruktura API emituje ustrukturyzowane logi dostępu w formacie NDJSON — jeden obiekt JSON na linię, gdzie każda linia zawiera ścieżkę żądania, status odpowiedzi, opóźnienie i zdekodowany lub surowy nagłówek Authorization. W ruchliwym serwisie pliki te szybko rosną: bramka obsługująca 10 000 żądań na minutę produkuje ponad 14 milionów wpisów dziennie. Przypadki użycia związane z bezpieczeństwem i zgodnością regularnie wymagają skanowania tych plików po fakcie — identyfikowania każdego żądania wykonanego przez skompromitowane konto usługi (analiza po incydencie), potwierdzania, że tokeny określonego użytkownika wygasły przed oknem dostępu do danych (audyt zgodności) lub wyodrębniania pełnego zestawu podmiotów, które uzyskały dostęp do wrażliwego punktu końcowego podczas okna serwisowego. Ponieważ pojedynczy plik logów może przekroczyć kilka gigabajtów, załadowanie go do pamięci za pomocą readFileSync nie jest wykonalne. Strumienie readline Node.js przetwarzają plik linia po linii ze stałym narzutem pamięci, umożliwiając skanowanie dowolnie dużych logów na standardowym laptopie programistycznym.
Problem "plik zbyt duży dla pamięci" nie wystąpi przy pojedynczych JWT, ponieważ pojedynczy token rzadko przekracza kilka kilobajtów. Scenariuszem, który się pojawia, jest skanowanie dużego logu dostępów lub dziennika audytu pod kątem tokenów JWT, dekodowanie każdego z nich i wyodrębnianie określonych claimów. Strumienie Node.js obsługują to bez ładowania całego pliku.
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 {
// Pomiń uszkodzone linie
}
}
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 {
// Nieprawidłowy JWT
}
}
}
console.log(`Found ${subjects.size} unique subjects:`);
for (const sub of subjects) console.log(` ${sub}`);
}
extractUniqueSubjects("./logs/gateway-2026-03.log");readFileSync zablokuje pamięć i wywoła pauzy GC. Podejście z readline przetwarza jedną linię na raz ze stałym zużyciem pamięci.Typowe błędy
Problem: atob() zwraca ciąg Latin-1. Znaki wielobajtowe UTF-8 (emoji, CJK, znaki akcentowane) są rozdzielane na wiele znaków i wychodzą uszkodzone.
Rozwiązanie: Przekonwertuj wyjście atob() na Uint8Array, a następnie przepuść przez new TextDecoder('utf-8').
// Psuje się przy claimach ładunku spoza ASCII
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name pokazuje "ç°ä¸å¤ªé\x83\x8E" zamiast "田中太郎"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 poprawnie pokazuje "田中太郎"Problem: atob() rzuca "InvalidCharacterError", ponieważ base64url używa - i _ zamiast + i /.
Rozwiązanie: Zamień - na + i _ na / przed wywołaniem atob(). Node.js Buffer.from() z 'base64url' obsługuje to automatycznie.
// Rzuca: 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); // teraz działaProblem: Każdy może stworzyć JWT z dowolnym ładunkiem. Dekodowanie tylko odczytuje dane — nie dowodzi, że token został wydany przez twój serwer uwierzytelniania.
Rozwiązanie: Po stronie serwera zawsze weryfikuj podpis używając jose.jwtVerify() lub jsonwebtoken.verify(). Dekodowanie bez weryfikacji jest akceptowalne dla wyświetlania claimów użytkownika po stronie klienta.
// NIEBEZPIECZNE: zdekodowane, ale nie zweryfikowane
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
grantAdminAccess(); // atakujący może to sfałszować
}import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
grantAdminAccess(); // bezpieczne — podpis jest zweryfikowany
}Problem: JWT exp jest w sekundach od epoki, ale Date.now() zwraca milisekundy. Porównanie zawsze będzie mówić, że token jest ważny, ponieważ znacznik czasu w milisekundach jest 1000 razy większy.
Rozwiązanie: Podziel Date.now() przez 1000 i zaokrąglij w dół przed porównaniem z exp.
// Błąd: Date.now() jest w milisekundach, exp jest w sekundach
if (payload.exp > Date.now()) {
console.log("Token is valid"); // zawsze prawda — błędne!
}const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
console.log("Token is valid"); // poprawne porównanie
}Metody dekodowania JWT — szybkie porównanie
Używaj atob() + TextDecoder do dekodowania po stronie przeglądarki, gdy chcesz tylko wyświetlić claime użytkownikowi. Używaj Buffer.from() w skryptach i narzędziach CLI Node.js. Sięgaj po jose w momencie, gdy musisz zweryfikować podpis, czyli w każdym oprogramowaniu pośredniczącym uwierzytelniania po stronie serwera. Pakiet jwt-decode to lekka alternatywa, jeśli chcesz jednej funkcji API do dekodowania w przeglądarce bez weryfikacji. Do szybkiej inspekcji wizualnej bez pisania kodu, wklej swój token do narzędzia JWT Decoder.
Często zadawane pytania
Jak zdekodować token JWT w JavaScript bez biblioteki?
Podziel token po znaku ".", weź drugi segment (ładunek), znormalizuj kodowanie base64url zamieniając - na + i _ na /, uzupełnij znakami =, a następnie wywołaj atob() i TextDecoder, aby uzyskać ciąg JSON w UTF-8. Przepuść wynik przez JSON.parse() i masz obiekt z claimami. Żaden pakiet npm nie jest wymagany. Podejście to działa we wszystkich nowoczesnych przeglądarkach oraz w Node.js 18+. Jeśli chcesz odczytać nagłówek, zastosuj te same kroki do pierwszego segmentu. Pamiętaj, że to daje surowe dane bez weryfikacji podpisu — traktuj wynik jako dane tylko do wyświetlania, chyba że zweryfikujesz podpis po stronie serwera.
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" }Jaka jest różnica między atob() a Buffer.from() przy dekodowaniu JWT?
atob() to API przeglądarki, które dekoduje standardowy Base64 do ciągu binarnego Latin-1. Nie rozumie bezpośrednio kodowania base64url, więc najpierw musisz zastąpić znaki - i _. Buffer.from(segment, "base64url") to API Node.js, które obsługuje alfabet base64url natywnie i zwraca Buffer, na którym możesz wywołać .toString("utf-8"). Używaj atob() w przeglądarce, Buffer.from() w Node.js. Trzecia opcja — wolniejsza, ale historycznie popularna — to sztuczka z percent-encodingiem i decodeURIComponent, jednak wzorzec ten opiera się na przestarzałej funkcji escape() w niektórych starszych fragmentach kodu i należy go unikać w nowym kodzie. W przypadku kodu izomorficznego działającego w obu środowiskach sprawdź typeof Buffer !== "undefined" i rozgałęź odpowiednio.
// Przeglądarka
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");Dlaczego atob() nie działa poprawnie dla claimów zawierających znaki spoza ASCII?
atob() zwraca ciąg Latin-1, gdzie każdy znak odpowiada jednemu bajtowi. Wielobajtowe sekwencje UTF-8 (emoji, znaki CJK, znaki akcentowane spoza Latin-1) są rozdzielane na wiele znaków, co daje uszkodzone dane wyjściowe. Rozwiązaniem jest najpierw konwersja ciągu binarnego do Uint8Array, a następnie przekazanie tej tablicy do new TextDecoder("utf-8").decode(). API TextDecoder poprawnie składa wielobajtowe sekwencje. Problem ten łatwo przeoczyć podczas programowania, ponieważ większość ładunków JWT zawiera jedynie ASCII — identyfikatory użytkowników, znaczniki czasu i nazwy ról — błąd ujawnia się dopiero, gdy claim zawiera nieascii-ową nazwę wyświetlaną lub zlokalizowany ciąg. Zawsze używaj ścieżki z TextDecoder w nowym kodzie, nawet gdy obecne ładunki zawierają wyłącznie ASCII, ponieważ claime mogą się zmieniać wraz z rozwojem aplikacji.
// Błędnie: atob zwraca Latin-1, znaki wielobajtowe są uszkodzone
const broken = atob(base64); // "ð\x9F\x8E\x89" zamiast emoji
// Poprawnie: konwersja do tablicy bajtów, następnie TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);Czy mogę zweryfikować podpis JWT w JavaScript?
Dekodowanie i weryfikacja to różne operacje. Dekodowanie jedynie odczytuje ładunek, który nie jest zaszyfrowany. Weryfikacja sprawdza podpis względem sekretu (HMAC) lub klucza publicznego (RSA/ECDSA). Biblioteka jose obsługuje oba przypadki w przeglądarce za pośrednictwem Web Crypto API oraz w Node.js. Pakiet jsonwebtoken działa tylko w Node.js. Nigdy nie ufaj zdekodowanym claimom bez weryfikacji podpisu po stronie serwera. Po stronie klienta dopuszczalne jest dekodowanie JWT w celu odczytania nazwy wyświetlanej użytkownika lub czasu wygaśnięcia, ale wszelkie decyzje kontroli dostępu — sprawdzanie, czy użytkownik ma określoną rolę lub uprawnienie — muszą być podejmowane w kodzie serwerowym po weryfikacji. Atakujący rozumiejący format JWT może spreparować token z dowolnymi claimami, a twoje sprawdzenie po stronie klienta zakończy się powodzeniem.
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); // zweryfikowane claimeJak sprawdzić, czy JWT wygasł w JavaScript?
Zdekoduj ładunek i odczytaj claim exp, który jest znacznikiem czasu Unix w sekundach. Porównaj go z bieżącym czasem używając Math.floor(Date.now() / 1000). Jeśli bieżący czas jest większy niż exp, token wygasł. Pamiętaj: wartość exp jest w sekundach od epoki, nie w milisekundach, więc konieczne jest podzielenie Date.now() przez 1000. W praktyce wbuduj małą rezerwę na rozbieżność zegarów — sprawdzanie, czy token wygaśnie w ciągu następnych 30 sekund zamiast ściśle w przeszłości, zapobiega przypadkom brzegowym, gdzie token jest jeszcze technicznie ważny podczas dekodowania, ale wygasa zanim następne wywołanie API zdoła go przetworzyć. Obsłuż również przypadek, gdy exp jest całkowicie nieobecny, co oznacza, że token nigdy nie wygasa.
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 falseJak napisać izomorficzny kod dekodowania JWT działający zarówno w Node.js, jak i w przeglądarce?
Sprawdź istnienie globalThis.Buffer. Jeśli istnieje, jesteś w Node.js i możesz użyć Buffer.from(segment, "base64url").toString("utf-8"). Jeśli nie istnieje, jesteś w przeglądarce i powinieneś użyć atob() z podejściem TextDecoder. Opakuj tę weryfikację w jedną funkcję decodeBase64Url i używaj jej wszędzie. Ma to największe znaczenie dla pakietów narzędziowych, komponentów systemu projektowania i wszelkiego współdzielonego kodu w monorepo, który jest importowany zarówno przez komponent serwerowy Next.js, jak i przez przeglądarkowy komponent React. Trzymanie wykrywania środowiska w jednym miejscu oznacza, że wystarczy je zaktualizować w jednym miejscu, jeśli środowisko uruchomieniowe się zmieni — na przykład gdy Deno doda pełne wsparcie dla Buffer lub nowe środowisko uruchomieniowe Edge będzie wymagało innej ścieżki kodu.
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);
}Powiązane narzędzia
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.