JWT kelihatan seperti string acak, tapi dua dari tiga bagiannya adalah JSON yang di-encode base64url, dan siapa pun yang memegang token itu bisa membacanya. Kamu bisa decode JWT dengan satu baris di terminal, jadi tidak perlu paste token production ke website hanya untuk melihat isinya.
Biasanya ini dibutuhkan saat request balik 401 dan kamu memegang access token dan refresh token. Dengan decode keduanya, kamu tahu mana yang sudah expired dan apa isi payload masing-masing.
Jawaban cepat
Decode payload dengan jq:
echo "$TOKEN" | jq -R 'split(".") | .[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
Atau dengan Node.js, yang sudah paham base64url:
node -e 'const [h,p]=process.argv[1].split(".");console.log(JSON.parse(Buffer.from(h,"base64url")),JSON.parse(Buffer.from(p,"base64url")))' "$TOKEN"
Ganti .[1] jadi .[0] di versi jq untuk mendapatkan header. Dua gsub itu wajib: JWT memakai alfabet base64url (- dan _, bukan + dan /), dan @base64d di jq menolak karakter itu dengan error is not valid base64 data.
Tiga bagian JWT
Ini contoh token yang di-sign dengan secret sekali pakai:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9zw6kgUml6YWwiLCJlbWFpbCI6Impvc2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiJ1c2VyXzhmMmMiLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTE3NjMyMDAsImV4cCI6MTc5MTc2NDEwMH0.I7iI1g6EZLoyD3cgEPgLNS80sh6t4mvSpHdSsowirvI
Token ini dipecah berdasarkan titik menjadi header.payload.signature.
Header menjelaskan cara token di-sign:
{ "alg": "HS256", "typ": "JWT" }
HS256 artinya HMAC dengan SHA-256 dan shared secret. Kamu juga akan sering melihat RS256 atau ES256 (di-sign dengan private key, dicek dengan public key), dan biasanya ada kid yang memberi tahu server key mana yang dipakai.
Payload berisi claim:
{
"name": "José Rizal",
"email": "jose@example.com",
"role": "admin",
"sub": "user_8f2c",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": 1791763200,
"exp": 1791764100
}
Nama tiga huruf itu adalah registered claim dari RFC 7519:
iss: siapa yang menerbitkan tokensub: token ini tentang siapa, biasanya user IDaud: token ini untuk siapa, biasanya API kamuexp,iat,nbf: kapan expired, kapan diterbitkan, belum berlaku sebelum kapanjti: ID unik token, dipakai untuk daftar revocation
Sisanya (name, email, role) bebas, terserah issuer mau menambahkan apa.
Signature ada di bagian terakhir. Issuer menghitungnya dari header.payload (dua string ter-encode yang digabung dengan titik) memakai secret atau private key. Isinya biner, jadi kalau di-decode tidak ada yang bisa dibaca. Signature hanya berarti untuk kode yang punya key-nya.
Membaca exp, iat, dan nbf
Claim waktu adalah Unix timestamp dalam detik. Konversi dengan date:
date -u -d @1791764100
# Mon Oct 12 00:15:00 UTC 2026
Di macOS pakai date -u -r 1791764100.
exp - iat adalah umur token. Di contoh ini 900 detik, alias 15 menit, umur yang umum untuk access token. Kalau token yang sedang kamu debug berlaku 30 hari, itu patut ditanyakan.
Di JavaScript, kalikan 1000 dulu sebelum membandingkan:
const expiresAt = new Date(payload.exp * 1000)
const isExpired = expiresAt.getTime() < Date.now()
payload.exp < Date.now() membandingkan detik dengan milidetik, dan hasilnya selalu true untuk semua token, termasuk yang baru diterbitkan sedetik lalu.
Decode JWT di browser
atob kelihatannya pilihan yang paling jelas, tapi gagal dalam dua cara. Ia melempar Invalid character untuk setiap - atau _, dan karakter itu sering muncul di token asli. Kalau tidak error, hasilnya di-decode sebagai Latin-1, jadi José di payload tadi keluar sebagai José.
Versi ini menangani keduanya:
function decodeJwtPart(part) {
const base64 = part.replace(/-/g, '+').replace(/_/g, '/')
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
}
const [rawHeader, rawPayload] = token.split('.')
const header = decodeJwtPart(rawHeader)
const payload = decodeJwtPart(rawPayload)
Lewati bagian ketiga. Signature berupa data biner, jadi JSON.parse akan error.
Base64url juga membuang padding = di akhir. atob menerima input tanpa padding, tapi base64 -d di command line tidak, makanya contoh shell di atas memakai jq dan node.
Decode JWT tidak sama dengan verifikasi
Siapa pun bisa decode token, dan siapa pun bisa membuat token baru. Ambil contoh token tadi, ubah role jadi superadmin, dan pakai signature yang lama. Hasilnya tetap bisa di-decode, dan kode yang hanya decode akan percaya.
Di server, pakai library yang mengecek signature dan claim. Dengan jose:
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
Hasil tiap kasus dengan jose 5.10:
correct secret → OK, payload.sub = user_8f2c
wrong secret → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
role changed by hand → ERR_JWS_SIGNATURE_VERIFICATION_FAILED
after exp → ERR_JWT_EXPIRED
header alg: "none" → ERR_JOSE_ALG_NOT_ALLOWED
Tulis algorithms secara eksplisit. Library JWT lama memercayai alg di header, sehingga attacker bisa mengirim "alg": "none" dengan signature kosong dan token-nya diterima.
Helper yang hanya decode, seperti jwt-decode atau decodeJwt dari jose, boleh dipakai di frontend untuk hal seperti menampilkan nama user atau refresh token sebelum exp. Helper itu tidak boleh menentukan apa yang boleh dilakukan sebuah request.
Kenapa jangan paste token production ke sembarang website
JWT yang valid adalah bearer token. Siapa pun yang memegang string itu bisa memanggil API kamu sebagai user tersebut sampai exp, dan kebanyakan setup tidak bisa me-revoke satu JWT lebih awal karena server tidak menyimpannya. Token yang di-copy dari request production adalah kredensial aktif selama rentang waktu itu.
Saat kamu paste ke decoder online, kamu tidak bisa melihat apa yang dilakukan website itu. Mungkin semuanya di-decode di browser. Mungkin juga token-nya dikirim ke server mereka untuk analytics atau error logging.
Yang sebaiknya dilakukan:
- Decode secara lokal dengan perintah
jqataunodedi atas. - Kalau memakai tool web, buka tab Network di DevTools dulu, paste token-nya, lalu pastikan tidak ada request yang keluar.
- Pakai token dari environment dev atau staging, atau token yang sudah expired. Strukturnya sama.
- Kalau token production sudah telanjur masuk ke tempat yang salah, rotate yang bisa: logout user tersebut, dan kalau token-nya berumur panjang, rotate signing key.
Kalau tidak mau mengetik command, JWT decoder di situs ini menampilkan header, payload, dan exp/iat/nbf sebagai tanggal, serta menandai token yang sudah expired. Decode-nya berjalan di browser dan tidak mengirim apa pun, dan kamu bisa memastikannya dengan cek tab Network di atas.
Kesalahan yang sering terjadi
Menaruh rahasia di payload. Payload hanya di-encode, dan siapa pun bisa membaliknya. Jangan taruh password, API key, atau apa pun yang tidak akan kamu taruh di URL. Kalau isinya harus privat, kamu butuh JWE yang terenkripsi, yang punya lima bagian dipisah titik, bukan tiga.
Prefix Bearer ikut ter-copy. Kalau kamu meng-copy header Authorization, buang prefix-nya dulu, atau split(".") akan menghasilkan bagian pertama yang rusak. Di jq, tambahkan sub("^Bearer +";"") sebelum split.
Memercayai claim hasil decode di server. Membaca role dari token yang hanya di-decode tanpa verifikasi berarti siapa pun bisa menjadikan dirinya admin, seperti token yang diedit di atas.
Membandingkan exp langsung dengan Date.now(). Ini membandingkan detik dengan milidetik, jadi semua token akan terlihat expired.
Kesimpulan
Untuk decode JWT, pecah berdasarkan titik, decode base64url dua bagian pertama, lalu baca JSON-nya. Lakukan dengan jq atau node di mesin sendiri, jangan paste token aktif ke website. Decode hanya menunjukkan apa yang diklaim token. Di server, verifikasi signature dengan library seperti jose dan tulis algoritmanya secara eksplisit.