JWT Decoder JavaScript โ€” atob(), TextDecoder & jose

ยทJavaScript Performance EngineerยทDitinjau olehSophie LaurentยทDiterbitkan

Gunakan JWT Decoder gratis langsung di browser Anda โ€” tidak perlu instalasi.

Coba JWT Decoder Online โ†’

Setiap alur autentikasi yang pernah saya bangun pada akhirnya mencapai titik yang sama: ada JWT di dalam cookie, header, atau URL callback OAuth, dan Anda perlu membaca isinya. Dekoder JWT di JavaScript tidak memerlukan paket npm apa pun. Header dan payload token hanyalah JSON yang diencode dengan Base64url, dan baik browser maupun Node.js sudah dilengkapi semua yang diperlukan untuk mendekodenya. Panduan ini membahas pipeline JavaScript text decoder lengkap untuk JWT: memecah token, menormalisasi base64url ke Base64 standar, atob() dan TextDecoder untuk penanganan UTF-8 yang benar, Node.js Buffer.from(), verifikasi tanda tangan dengan jose, dan kesalahan umum yang sering menjebak developer. Untuk inspeksi cepat sekali pakai, coba JWT Decoder online sebagai gantinya. Semua contoh menargetkan ES2020+ dan Node.js 18+.

  • โœ“Pecah JWT menggunakan "." โ€” indeks 0 adalah header, indeks 1 adalah payload, indeks 2 adalah signature.
  • โœ“atob() mendekode Base64 tetapi mengembalikan Latin-1, bukan UTF-8. Gunakan TextDecoder atau Buffer.from() untuk klaim non-ASCII.
  • โœ“Buffer.from(segment, "base64url") menangani base64url secara native di Node.js โ€” tidak perlu penggantian karakter manual.
  • โœ“Dekoding BUKAN verifikasi. Jangan pernah mempercayai klaim dari JWT yang didekode tanpa memeriksa tanda tangan di sisi server.
  • โœ“Library jose melakukan keduanya: memverifikasi tanda tangan HS256/RS256/ES256 dan mengembalikan payload yang sudah didekode dalam satu panggilan.

Apa itu Dekoding JWT?

JSON Web Token adalah tiga segmen yang diencode Base64url dan dipisahkan oleh titik. Segmen pertama adalah header, segmen kedua adalah payload (klaim yang sebenarnya Anda butuhkan), dan segmen ketiga adalah tanda tangan kriptografis. Header adalah objek JSON kecil yang mendeskripsikan token itu sendiri. Bidang terpentingnya adalah alg โ€” algoritma penandatanganan (misalnya, HS256, RS256, ES256). Bidang typ hampir selalu "JWT", dan bidang opsional kid mengidentifikasi kunci mana yang digunakan untuk menandatangani token โ€” penting saat penyedia identitas merotasi kunci dan menerbitkan endpoint JWKS dengan beberapa kunci publik.

Payload membawa klaim. RFC 7519 mendefinisikan tujuh nama klaim terdaftar: sub (subject โ€” biasanya ID pengguna), iss (issuer โ€” URL server autentikasi), aud (audience โ€” API yang dituju token), iat (timestamp diterbitkan), exp (timestamp kedaluwarsa), nbf (timestamp not-before), dan jti (JWT ID โ€” digunakan untuk mencegah serangan replay). Semua timestamp dalam detik epoch Unix, bukan milidetik. Segmen tanda tangan adalah biner mentah โ€” digest HMAC berkunci atau tanda tangan digital asimetris. Segmen ini diencode Base64url seperti segmen lainnya, tetapi byte-nya bukan JSON dan tidak memiliki struktur yang dapat dibaca manusia.

Dalam praktiknya, Anda mendekode JWT di JavaScript untuk tiga alasan umum. Pertama, debugging: Anda memiliki token dari alur OAuth atau lingkungan pengujian dan ingin mengonfirmasi bahwa klaim sesuai dengan yang seharusnya diterbitkan oleh server autentikasi. Kedua, membaca klaim pengguna untuk tujuan tampilan di sisi klien โ€” menampilkan nama pengguna yang sedang masuk, URL avatar, atau badge peran dari payload token tanpa panggilan API tambahan. Ketiga, memeriksa kedaluwarsa sebelum melakukan refresh: jika exp berada dalam 60 detik ke depan, lakukan silent refresh sebelum panggilan API berikutnya daripada menunggu respons 401.

Dekoding tidak memeriksa apakah token valid atau telah dimanipulasi. Itu adalah operasi terpisah yang disebut verifikasi, yang memerlukan rahasia HMAC atau kunci publik RSA/ECDSA. Siapa pun dapat mendekode JWT. Hanya pemegang kunci yang benar yang dapat memverifikasinya. Perbedaan ini sering menjebak banyak developer, terutama saat membangun alur autentikasi sisi klien di mana klaim yang didekode ditampilkan tetapi tidak boleh pernah dipercaya untuk keputusan otorisasi tanpa pemeriksaan backend yang terverifikasi.

Before ยท json
After ยท json
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Header
{ "alg": "HS256" }

// Payload
{
  "sub": "usr_921f",
  "role": "admin",
  "iat": 1711610000
}

atob() + TextDecoder โ€” Dekode JWT Native Browser

Pipeline native browser untuk mendekode JWT memiliki empat langkah. Pertama, pecah string token menggunakan "." untuk mendapatkan tiga segmen. Kedua, normalisasi segmen base64url dengan mengganti - dengan + dan _ dengan /, lalu tambahkan padding dengan karakter = hingga panjangnya merupakan kelipatan 4. Ketiga, panggil atob() untuk mendekode Base64 menjadi string biner. Keempat, konversi string biner ke UTF-8 yang benar menggunakan TextDecoder. Langkah terakhir itu penting karena atob() mengembalikan Latin-1. Karakter multi-byte โ€” emoji, teks CJK, karakter beraksen di atas rentang Latin-1 โ€” akan keluar kacau tanpa langkah JavaScript text decoder tersebut.

JavaScript โ€” dekode JWT minimal
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 }

Langkah padding mudah terlewatkan. JWT menghapus karakter = di akhir segmen Base64url-nya karena spesifikasi JWT (RFC 7515) mendefinisikan base64url tanpa padding. Namun atob() di beberapa engine browser melempar InvalidCharacterError jika panjang input tidak habis dibagi 4. Menambahkan padding secara defensif dengan padEnd() menghindari kasus tepi tersebut di semua lingkungan. Berikut adalah versi yang dapat digunakan ulang untuk mendekode header dan payload menjadi objek terpisah:

JavaScript โ€” dekode header dan payload
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"

Setelah Anda memiliki kedua fungsi ini, sebaiknya letakkan di modul utilitas bersama daripada menyalin logikanya ke berbagai file. Sebuah file src/lib/jwt.ts atau utils/jwt-decode.ts dengan bentuk return yang bertipe membuat maksud menjadi eksplisit di seluruh codebase. Dalam TypeScript, Anda dapat mengetik return sebagai { header: JwtHeader; payload: JwtPayload } di mana JwtHeader mencakup alg, typ, dan opsional kid, dan JwtPayload memperluas klaim terdaftar RFC 7519 dengan index signature untuk klaim kustom. Memusatkan logika dekode berarti saat Anda nanti ingin menambahkan penanganan error (menangkap segmen yang cacat) atau telemetri (mencatat kegagalan dekode), Anda hanya perlu memperbarui satu tempat.

Catatan:Langkah TextDecoder inilah yang membuat pipeline ini aman untuk klaim non-ASCII. Tanpanya, atob() mengembalikan string Latin-1 di mana urutan UTF-8 multi-byte terpecah ke beberapa karakter. Anda akan melihat sampah alih-alih emoji atau teks CJK. Selalu oper melalui new TextDecoder("utf-8") setelah atob().

Mendekode Klaim JWT UTF-8 dengan Karakter Multi-Byte

Payload JWT adalah JSON UTF-8 yang diencode sebagai base64url. Sebagian besar payload hanya berisi bidang ASCII seperti ID pengguna dan timestamp, sehingga developer tidak pernah menyadari bahwa atob() mengembalikan Latin-1 bukan UTF-8. Masalah muncul ketika sebuah klaim mengandung emoji, karakter Jepang, Sirilik, atau code point apa pun di atas U+00FF. Pola JavaScript decode UTF-8 mengharuskan konversi string biner ke array byte terlebih dahulu, lalu memprosesnya melalui TextDecoder.

JavaScript โ€” round-trip UTF-8 dengan emoji di payload JWT
// Mensimulasikan payload JWT dengan emoji dan karakter CJK
const payloadObj = {
  sub: "usr_e821",
  display_name: "็”ฐไธญๅคช้ƒŽ",
  team: "Platform ๐Ÿš€",
  region: "ap-northeast-1"
};

// Encode: objek โ†’ JSON โ†’ byte 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(/=+$/, "");

// Decode: base64url โ†’ base64 โ†’ string biner โ†’ byte โ†’ string 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); // "็”ฐไธญๅคช้ƒŽ" โ€” benar
console.log(result.team);         // "Platform ๐Ÿš€" โ€” benar

Ada pola fallback lama yang akan Anda temukan di codebase lama yang menggunakan decodeURIComponent dikombinasikan dengan trik percent-encoding. Pendekatan JavaScript decodeURIComponent ini bekerja karena mengenkode ulang setiap byte sebagai pasangan percent-hex, lalu decodeURIComponent menyusun kembali urutan UTF-8 multi-byte:

JavaScript โ€” fallback decodeURIComponent untuk UTF-8
function decodeBase64UrlLegacy(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(base64);
  // Konversi setiap karakter ke %XX hex, lalu decodeURIComponent menyusun kembali UTF-8
  const utf8 = decodeURIComponent(
    binary.split("").map(c =>
      "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
    ).join("")
  );
  return utf8;
}

// Bekerja untuk klaim non-ASCII tanpa TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));
Peringatan:Anda mungkin menemukan pola lama decodeURIComponent(escape(atob(segment))) di cuplikan utilitas JWT yang lebih lama. Fungsi escape() sudah usang dan non-standar. Ganti dengan pendekatan TextDecoder yang ditunjukkan di atas. Pola JavaScript unescape decoder memiliki masalah yang sama: unescape() sudah usang. Kedua fungsi tersebut mungkin akan dihapus dari engine JavaScript di masa depan.

Pipeline Dekode JWT โ€” Referensi Langkah

Setiap langkah dalam pipeline dekode JWT native browser, beserta API JavaScript yang digunakan dan apa yang dihasilkan:

Parameter / Langkah
Tipe
Deskripsi
token.split(".")
string[]
Memecah JWT menjadi segmen [header, payload, signature]
base64url โ†’ base64
string replace
Ganti - dengan +, _ dengan /, tambahkan padding = hingga kelipatan 4
atob(base64)
string
Mendekode string Base64 standar menjadi string biner (Latin-1)
TextDecoder("utf-8")
TextDecoder
Mengonversi Uint8Array berisi byte mentah menjadi string UTF-8 yang benar
JSON.parse()
object
Mengurai string JSON yang dihasilkan menjadi objek JavaScript

Ekuivalen Node.js menyatukan langkah 2 hingga 4 menjadi satu panggilan: Buffer.from(segment, "base64url").toString("utf-8"). Opsi encoding "base64url" menangani konversi alfabet dan padding secara internal.

Buffer.from() โ€” String Decoder Node.js untuk JWT

Node.js memiliki jalur yang jauh lebih sederhana. Kelas Buffer menerima encoding "base64url" secara langsung, sehingga Anda melewati penggantian karakter manual dan padding. Ini adalah jalur JavaScript string decoder untuk kode sisi server. Satu baris mengubah segmen JWT menjadi string UTF-8, dan menangani karakter multi-byte dengan benar tanpa langkah tambahan.

Node.js 18+ โ€” dekode JWT dengan Buffer
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 }

Inilah pendekatan yang selalu saya gunakan di setiap proyek Node.js. Lebih pendek, lebih cepat, dan sudah menangani UTF-8 dengan benar. Tidak perlu TextDecoder, tidak perlu penggantian karakter, tidak perlu matematika padding. Kelas Buffer adalah JavaScript string decoder yang menangani alfabet base64url secara native, yang mengeliminasi seluruh kelas bug terkait substitusi karakter. Jika kode Anda perlu berjalan di browser dan Node.js, lihat FAQ di bagian bawah untuk fungsi wrapper isomorfis yang mendeteksi lingkungan saat runtime.

Berikut adalah contoh yang lebih lengkap yang menunjukkan cara mengekstrak klaim JWT umum dan mengonversi timestamp ke tanggal yang dapat dibaca, yaitu pola yang paling sering Anda gunakan dalam middleware dan handler route API:

Node.js โ€” ekstraksi klaim JWT praktis
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"]
// }

Dalam layanan Node.js produksi, pola dekode Buffer.from() muncul di tiga tempat berulang. Yang pertama adalah middleware logging request: Anda mendekode header Authorization yang masuk untuk melampirkan userId dan org ke setiap entri log terstruktur tanpa round-trip jaringan tambahan ke server autentikasi. Yang kedua adalah debugging: Anda mencetak klaim token yang didekode ke konsol selama pengembangan untuk mengonfirmasi scope yang benar telah diterbitkan sebelum menulis asersi pengujian. Yang ketiga adalah refresh token proaktif dalam API gateway. Daripada meneruskan token ke upstream dan membiarkan layanan downstream mengembalikan 401 saat token kedaluwarsa di tengah request, gateway mendekode token di edge, membaca klaim exp, dan memicu refresh jika kedaluwarsa dalam 30 detik ke depan. Ini mengeliminasi kelas kegagalan autentikasi sementara yang sulit direproduksi dan membuat frustrasi untuk di-debug.

Catatan:Encoding "base64url" ditambahkan di Node.js 15.7.0. Jika Anda terjebak di Node.js 14 atau lebih lama, gunakan fallback Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") yang bekerja dengan cara yang sama tetapi memerlukan penggantian karakter manual.

Dekode JWT dari File dan Respons API

Dua skenario terus muncul. Yang pertama adalah membaca JWT dari file lokal: token yang disimpan saat pengembangan, fixture pengujian, atau file yang dibuang selama insiden untuk analisis post-mortem. Yang kedua adalah mengekstrak JWT dari respons HTTP, biasanya bidang access_token dalam body respons token OAuth atau header Authorization. Keduanya memerlukan penanganan error karena token yang cacat, file yang terpotong, dan error jaringan adalah realitas sehari-hari. Token yang valid minggu lalu mungkin memiliki spasi atau baris baru di akhir akibat copy-paste. Body respons mungkin berupa HTML bukan JSON jika server autentikasi mengembalikan halaman error.

Baca JWT dari File (Node.js)

Node.js โ€” dekode JWT dari file dengan penanganan error
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);
}

Ekstrak JWT dari Respons API (fetch)

JavaScript โ€” dekode JWT dari respons API
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);
}

// Penggunaan
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);
}

Dekoding JWT dari Command-Line

Terkadang Anda hanya ingin mengintip token dari terminal tanpa menulis skrip. Node.js tersedia di sebagian besar mesin developer, sehingga one-liner berfungsi dengan baik. jq menangani pretty-printing.

bash โ€” dekode payload JWT dari terminal
# Dekode payload JWT dengan one-liner 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'))))"

# Pipe ke jq untuk output yang rapi
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 .

# Dekode header dan payload sekaligus
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()));
  });
"

Jika Anda lebih suka bash murni tanpa Node.js, oper segmen melalui base64 -d setelah memperbaiki karakter base64url dengan tr:

bash โ€” dekode JWT dengan bash murni tanpa Node.js
# Bash murni: dekode payload JWT tanpa Node.js
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# Varian macOS (base64 -D bukan -d)
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .

Untuk inspeksi visual cepat tanpa terminal sama sekali, tempelkan token Anda ke JWT Decoder ToolDeck untuk tampilan berdampingan dari ketiga segmen dengan label klaim berkode warna dan status kedaluwarsa.

jose โ€” Verifikasi dan Dekoding dalam Satu Library

Untuk middleware autentikasi produksi, Anda memerlukan verifikasi tanda tangan, bukan hanya dekoding. Library jose adalah pilihan terbaik. Library ini bekerja di Node.js maupun browser (melalui Web Crypto API), mendukung HS256, RS256, ES256, EdDSA, dan JWE (token terenkripsi), dan tidak memiliki dependensi native. Instal dengan npm install jose.

JavaScript โ€” jose: verifikasi token HS256
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);
  }
}
JavaScript โ€” jose: verifikasi RS256 dengan endpoint JWKS
import * as jose from "jose";

// Ambil set kunci publik dari identity provider
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, dll. sekarang sudah terverifikasi
  req.userId = payload.sub;
} catch (err) {
  return res.status(401).json({ error: "Invalid token" });
}

Saat memutuskan antara jose dan paket jsonwebtoken yang lebih lama, perbedaan utamanya adalah ruang lingkup runtime. jsonwebtoken hanya untuk Node.js โ€” bergantung pada built-in crypto dan tidak bisa di-bundle untuk browser. jose sepenuhnya isomorfis: menggunakan Web Crypto API, yang tersedia di semua browser modern, Node.js 16+, Deno, Bun, dan Cloudflare Workers. Jika logika autentikasi Anda berada di file middleware Next.js (yang berjalan di Edge Runtime), atau di Cloudflare Worker, atau di utilitas bersama yang diimpor oleh kode server maupun klien, jose adalah pilihan yang tepat karena tidak memiliki dependensi native dan dapat diinstal tanpa langkah build. jsonwebtoken masih wajar digunakan untuk aplikasi server Node.js murni di mana Anda membutuhkan ekosistem helper penandatanganan yang lebih luas dan tidak berencana menjalankan kode di lingkungan edge. Dalam proyek greenfield di tahun 2026, gunakan jose sebagai default kecuali Anda memiliki alasan khusus untuk memilih API yang lebih lama.

Jika Anda hanya perlu dekode tanpa verifikasi, jose menyediakan jose.decodeJwt(token) yang mengembalikan payload dan jose.decodeProtectedHeader(token) untuk header. Ini adalah fungsi kenyamanan yang melakukan dekoding Base64url secara internal. Tetapi alasan utama menggunakan jose adalah bahwa Anda jarang harus mendekode tanpa juga memverifikasi. Jika Anda berada di sisi klien dan hanya perlu menampilkan nama tampilan atau URL avatar pengguna dari klaim token, decode-only tidak masalah. Di sisi server, selalu verifikasi. Saya pernah melihat sistem produksi yang mendekode klaim JWT untuk keputusan kontrol akses tanpa memeriksa tanda tangan, dan itu adalah pintu terbuka bagi penyerang mana pun yang memahami format JWT.

JavaScript โ€” jose.decodeJwt untuk skenario decode-only
import * as jose from "jose";

// Decode-only: tidak perlu secret, tidak ada verifikasi
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"

// Periksa kedaluwarsa tanpa verifikasi (tampilan sisi klien)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  console.log("Token telah kedaluwarsa โ€” arahkan ke halaman login");
}

Output Terminal dengan Sorotan Sintaks

Saat men-debug token JWT di alat CLI Node.js atau selama insiden, output berkode warna membuat perbedaan nyata. Library chalk yang dipadukan dengan JSON.stringify sudah cukup. Instal dengan npm install chalk.

Node.js โ€” output dekode JWT dengan warna
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)));

  // Sorot status kedaluwarsa
  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]);
// Jalankan: node jwt-debug.mjs "eyJhbGci..."
Catatan:Output warna hanya untuk terminal. Jangan gunakan chalk saat menulis klaim JWT ke file log, respons API, atau bidang database. Kode escape ANSI akan muncul sebagai sampah di konteks non-terminal.

Memproses JWT dari File Log Besar

Infrastruktur API modern menghasilkan access log terstruktur dalam format NDJSON โ€” satu objek JSON per baris, dengan setiap baris berisi path request, status respons, latensi, dan header Authorization yang didekode atau mentah. Pada layanan yang sibuk, file-file ini tumbuh dengan cepat: gateway yang menangani 10.000 request per menit menghasilkan lebih dari 14 juta entri log per hari. Kasus penggunaan keamanan dan kepatuhan secara rutin mengharuskan pemindaian file-file ini setelah kejadian โ€” mengidentifikasi setiap request yang dibuat oleh akun layanan yang dibobol (analisis pasca-insiden), mengonfirmasi bahwa token pengguna tertentu sudah kedaluwarsa sebelum jendela akses data (audit kepatuhan), atau mengekstrak kumpulan lengkap subjek yang mengakses endpoint sensitif selama jendela pemeliharaan. Karena satu file log dapat melebihi beberapa gigabyte, memuatnya ke dalam memori dengan readFileSync tidak layak. Stream readline Node.js memproses file satu baris sekaligus dengan overhead memori yang konstan, sehingga praktis untuk memindai log berukuran sembarang pada laptop developer standar.

Anda tidak akan mengalami masalah "file terlalu besar untuk memori" dengan JWT individual, karena satu token jarang lebih dari beberapa kilobyte. Skenario yang memang muncul adalah memindai access log besar atau audit trail untuk token JWT, mendekode masing-masing, dan mengekstrak klaim tertentu. Stream Node.js menangani ini tanpa memuat seluruh file.

Node.js โ€” stream log NDJSON dan dekode JWT yang tertanam
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 {
      // Lewati baris yang cacat
    }
  }

  console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}

scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");
Node.js โ€” ekstrak subjek JWT unik dari stream log
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 {
        // Bukan JWT yang valid
      }
    }
  }

  console.log(`Found ${subjects.size} unique subjects:`);
  for (const sub of subjects) console.log(`  ${sub}`);
}

extractUniqueSubjects("./logs/gateway-2026-03.log");
Catatan:Beralih ke streaming ketika file log melebihi 50 MB. Memuat file NDJSON 500 MB dengan readFileSync akan memenuhi memori dan memicu jeda GC. Pendekatan readline memproses satu baris sekaligus dengan penggunaan memori yang konstan.

Kesalahan Umum

โŒ Menggunakan atob() tanpa TextDecoder untuk klaim non-ASCII

Masalah: atob() mengembalikan string Latin-1. Karakter UTF-8 multi-byte (emoji, CJK, karakter beraksen) terpecah ke beberapa karakter dan keluar kacau.

Solusi: Konversi output atob() ke Uint8Array, lalu oper melalui new TextDecoder('utf-8').

Before ยท JavaScript
After ยท JavaScript
// Gagal pada klaim payload non-ASCII
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name muncul sebagai "รงยฐรคยธยญรฅยคยชรฉ\x83\x8E" bukan "็”ฐไธญๅคช้ƒŽ"
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 dengan benar menampilkan "็”ฐไธญๅคช้ƒŽ"
โŒ Lupa penggantian karakter base64url ke base64

Masalah: atob() melempar "InvalidCharacterError" karena base64url menggunakan - dan _ alih-alih + dan /.

Solusi: Ganti - dengan + dan _ dengan / sebelum memanggil atob(). Node.js Buffer.from() dengan 'base64url' menangani ini secara otomatis.

Before ยท JavaScript
After ยท JavaScript
// Melempar: 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); // sekarang berfungsi
โŒ Mempercayai klaim JWT yang didekode tanpa verifikasi tanda tangan

Masalah: Siapa pun dapat membuat JWT dengan payload apa pun. Dekoding hanya membaca data โ€” tidak membuktikan token diterbitkan oleh server autentikasi Anda.

Solusi: Di sisi server, selalu verifikasi tanda tangan menggunakan jose.jwtVerify() atau jsonwebtoken.verify(). Decode-only dapat diterima untuk tampilan klaim pengguna di sisi klien.

Before ยท JavaScript
After ยท JavaScript
// BERBAHAYA: didekode tapi tidak diverifikasi
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
  grantAdminAccess(); // penyerang dapat memalsukan ini
}
import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
  grantAdminAccess(); // aman โ€” tanda tangan sudah diverifikasi
}
โŒ Membandingkan exp dengan Date.now() tanpa dibagi 1000

Masalah: JWT exp dalam detik sejak epoch, tetapi Date.now() mengembalikan milidetik. Perbandingan akan selalu mengatakan token valid karena timestamp milidetik 1000x lebih besar.

Solusi: Bagi Date.now() dengan 1000 dan floor hasilnya sebelum dibandingkan dengan exp.

Before ยท JavaScript
After ยท JavaScript
// Bug: Date.now() dalam milidetik, exp dalam detik
if (payload.exp > Date.now()) {
  console.log("Token is valid"); // selalu benar โ€” salah!
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
  console.log("Token is valid"); // perbandingan yang benar
}

Metode Dekode JWT โ€” Perbandingan Cepat

Metode
Lingkungan
Aman UTF-8
Verifikasi Tanda Tangan
Tipe Kustom
Perlu Instalasi
atob() + TextDecoder
Browser
โœ“
โœ—
T/A (baca saja)
Tidak
Buffer.from()
Node.js
โœ“
โœ—
T/A (baca saja)
Tidak
decodeURIComponent()
Browser (lama)
โœ“
โœ—
T/A (baca saja)
Tidak
jose
Keduanya
โœ“
โœ“ (JWS/JWE)
โœ“
npm install
jsonwebtoken
Node.js
โœ“
โœ“
โœ“
npm install
jwt-decode
Keduanya
โœ“
โœ—
T/A
npm install

Gunakan atob() + TextDecoder untuk dekode sisi browser ketika Anda hanya perlu menampilkan klaim kepada pengguna. Gunakan Buffer.from() di skrip Node.js dan alat CLI. Gunakan jose ketika Anda perlu memverifikasi tanda tangan, yaitu middleware autentikasi sisi server apa pun. Paket jwt-decode adalah alternatif ringan jika Anda menginginkan API satu fungsi untuk decode-only di browser. Untuk inspeksi visual cepat tanpa menulis kode, tempelkan token Anda ke alat JWT Decoder.

Pertanyaan yang Sering Diajukan

Bagaimana cara mendekode token JWT di JavaScript tanpa library?

Pecah token menggunakan ".", ambil segmen kedua (payload), normalisasi encoding base64url dengan mengganti - menjadi + dan _ menjadi /, tambahkan padding dengan karakter =, lalu panggil atob() diikuti TextDecoder untuk mendapatkan string JSON UTF-8. Oper hasilnya melalui JSON.parse() dan Anda akan mendapat objek klaim. Tidak perlu paket npm. Pendekatan ini bekerja di semua browser modern dan di Node.js 18+. Jika Anda juga ingin membaca header, terapkan langkah dekoding yang sama pada segmen pertama. Perlu diingat bahwa ini memberikan data mentah tanpa verifikasi tanda tangan โ€” perlakukan hasilnya hanya untuk ditampilkan kecuali Anda memverifikasi tanda tangan di sisi server.

JavaScript
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" }

Apa perbedaan antara atob() dan Buffer.from() untuk dekoding JWT?

atob() adalah API browser yang mendekode Base64 standar menjadi string biner Latin-1. API ini tidak memahami encoding base64url secara langsung, sehingga Anda harus mengganti karakter - dan _ terlebih dahulu. Buffer.from(segment, "base64url") adalah API Node.js yang menangani alfabet base64url secara native dan mengembalikan Buffer yang bisa dipanggil .toString("utf-8"). Gunakan atob() di browser, Buffer.from() di Node.js. Opsi ketiga โ€” yang lebih lambat namun umum secara historis โ€” adalah trik percent-encoding decodeURIComponent, tetapi pola ini mengandalkan fungsi escape() yang sudah usang di beberapa cuplikan lama dan sebaiknya dihindari di kode baru. Untuk kode isomorfis yang berjalan di kedua lingkungan, periksa typeof Buffer !== "undefined" dan buat percabangan yang sesuai.

JavaScript
// Browser
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));

// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");

Mengapa atob() gagal pada klaim JWT yang mengandung karakter non-ASCII?

atob() mengembalikan string Latin-1 di mana setiap karakter dipetakan ke satu byte. Urutan UTF-8 multi-byte (emoji, karakter CJK, huruf beraksen di atas Latin-1) terpecah ke beberapa karakter, menghasilkan output yang tidak terbaca. Solusinya adalah mengonversi string biner ke Uint8Array terlebih dahulu, lalu mengoper array tersebut ke new TextDecoder("utf-8").decode(). API TextDecoder menyusun kembali urutan multi-byte dengan benar. Masalah ini mudah terlewatkan saat pengembangan karena sebagian besar payload JWT hanya berisi ID pengguna ASCII, timestamp, dan nama peran โ€” bug baru muncul ketika sebuah klaim berisi nama tampilan non-ASCII atau string yang dilokalkan. Selalu gunakan jalur TextDecoder pada kode baru meski payload Anda saat ini hanya ASCII, karena klaim dapat berubah seiring berkembangnya aplikasi.

JavaScript
// Rusak: atob mengembalikan Latin-1, karakter multi-byte kacau
const broken = atob(base64); // "รฐ\x9F\x8E\x89" bukan emoji yang sebenarnya

// Diperbaiki: konversi ke array byte, lalu TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);

Bisakah saya memverifikasi tanda tangan JWT di JavaScript?

Dekoding dan verifikasi adalah operasi yang berbeda. Dekoding hanya membaca payload, yang tidak dienkripsi. Verifikasi memeriksa tanda tangan terhadap sebuah rahasia (HMAC) atau kunci publik (RSA/ECDSA). Library jose mendukung keduanya di browser melalui Web Crypto API dan di Node.js. Paket jsonwebtoken hanya bekerja di Node.js. Jangan pernah mempercayai klaim yang didekode tanpa memverifikasi tanda tangan di sisi server. Di sisi klien, mendekode JWT untuk membaca nama tampilan pengguna atau waktu kedaluwarsa adalah hal yang dapat diterima, namun setiap keputusan kontrol akses โ€” memeriksa apakah pengguna memiliki peran atau izin tertentu โ€” harus dilakukan di kode sisi server setelah verifikasi. Penyerang yang memahami format JWT dapat membuat token dengan klaim sembarang dan pemeriksaan sisi klien Anda akan lolos.

JavaScript
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); // klaim yang sudah diverifikasi

Bagaimana cara memeriksa apakah JWT sudah kedaluwarsa di JavaScript?

Dekode payload dan baca klaim exp, yang merupakan timestamp Unix dalam detik. Bandingkan dengan waktu saat ini menggunakan Math.floor(Date.now() / 1000). Jika waktu saat ini lebih besar dari exp, token sudah kedaluwarsa. Ingat: nilai exp dalam detik sejak epoch, bukan milidetik, sehingga membagi Date.now() dengan 1000 adalah wajib. Dalam praktiknya, tambahkan buffer kecil untuk perbedaan jam โ€” memeriksa apakah token kedaluwarsa dalam 30 detik ke depan daripada ketat di masa lalu mencegah kasus tepi di mana token masih secara teknis valid saat Anda mendekodenya tetapi kedaluwarsa pada saat panggilan API berikutnya memprosesnya. Tangani juga kasus di mana exp sama sekali tidak ada, yang berarti token tidak pernah kedaluwarsa.

JavaScript
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 atau false

Bagaimana cara menulis kode dekode JWT isomorfis yang bekerja di Node.js dan browser?

Periksa keberadaan globalThis.Buffer. Jika ada, Anda berada di Node.js dan dapat menggunakan Buffer.from(segment, "base64url").toString("utf-8"). Jika tidak ada, Anda berada di browser dan sebaiknya menggunakan atob() dengan pendekatan TextDecoder. Bungkus pemeriksaan ini dalam satu fungsi decodeBase64Url dan gunakan di mana saja. Ini paling penting untuk paket utilitas, komponen sistem desain, dan kode bersama yang berada dalam paket monorepo yang diimpor oleh komponen server Next.js maupun komponen React browser. Menjaga deteksi lingkungan di satu tempat berarti Anda hanya perlu memperbaruinya di satu tempat jika runtime berubah โ€” misalnya, saat Deno menambahkan dukungan Buffer penuh atau runtime edge baru memerlukan jalur kode yang berbeda.

JavaScript
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);
}

Alat Terkait

Tersedia juga dalam:Python
MW
Marcus WebbJavaScript Performance Engineer

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.

SL
Sophie LaurentPeninjau teknis

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.