JWT — kapan dipakai, kapan tidak
JWT sering dipilih karena terdengar modern, lalu dipakai persis seperti sesi tapi tanpa kemampuan mencabutnya. Materi ini tentang membuat pilihan itu sadar, dan tentang jebakan implementasi yang tercatat di RFC 8725.
Intisari
- JWT ditandatangani, bukan dienkripsi. Isinya bisa dibaca siapa pun yang memegangnya.
- Tidak bisa dicabut sampai kedaluwarsa — kecuali kamu menambahkan daftar cabut, yang berarti kamu kembali butuh database.
- Selalu periksa algoritmanya saat verifikasi. Menerima
algdari token adalah kerentanan klasik (none, HS256 vs RS256). - Pola yang sehat: access token pendek (5–15 menit) + refresh token panjang yang tersimpan dan bisa dicabut.
- Untuk aplikasi web dengan browser, sesi biasanya lebih baik. JWT unggul untuk API antar layanan dan klien mobile.
Isi sebuah JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc.. . 4pcPyMD09olPSyXn
└──────────── header ─────────────┘ └──────── payload ────────┘ └─── signature ──┘
{"alg":"HS256"} {"sub":"42","exp":...} HMAC(header.payload)
Header dan payload cuma base64 — bukan enkripsi. Siapa pun yang memegang token bisa membacanya di jsonwebtoken.io dalam tiga detik. Jangan pernah menaruh data pribadi, peran internal yang sensitif, atau apa pun yang tidak boleh dilihat pemiliknya sendiri di dalam payload.
Sesi versus JWT
| Sesi server | JWT | |
|---|---|---|
| Verifikasi | Cari di Redis/DB | Cek tanda tangan — tanpa I/O |
| Pencabutan | Seketika | Tidak bisa, sampai kedaluwarsa |
| Ganti peran/izin | Berlaku di permintaan berikutnya | Berlaku setelah token lama habis |
| Ukuran per permintaan | ~40 byte | 300–1000 byte |
| Cocok untuk | Aplikasi web browser | API antar layanan, mobile, SSO |
Alasan yang paling sering dipakai untuk memilih JWT — "supaya stateless dan bisa diskalakan" — jarang relevan. Pencarian sesi di Redis butuh di bawah satu milidetik, sementara satu permintaanmu sudah melakukan beberapa kueri database. Yang kamu tukar adalah kemampuan mengeluarkan pengguna sekarang juga — kemampuan yang akan kamu butuhkan pada hari sebuah akun disusupi.
Verifikasi yang benar
func (a *Auth) Verifikasi(tokenStr string) (Klaim, error) {
t, err := jwt.ParseWithClaims(tokenStr, &Klaim{},
func(t *jwt.Token) (any, error) {
// WAJIB: jangan percaya "alg" dari token. Tanpa pemeriksaan ini,
// penyerang bisa mengirim token ber-alg "none", atau menandatangani
// dengan HS256 memakai KUNCI PUBLIK-mu sebagai rahasia.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("alg tak terduga: %v", t.Header["alg"])
}
return a.rahasia, nil
},
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer("toko-api"),
jwt.WithAudience("toko-web"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
if err != nil {
return Klaim{}, fmt.Errorf("token tidak valid: %w", ErrTidakBerhak)
}
k, ok := t.Claims.(*Klaim)
if !ok || !t.Valid {
return Klaim{}, ErrTidakBerhak
}
return *k, nil
}
| Kerentanan | Pencegahan |
|---|---|
alg: none diterima | Daftar putih algoritma (WithValidMethods) |
| Kebingungan HS256 / RS256 | Periksa tipe metode penandatanganan secara eksplisit |
| Token dari penerbit lain diterima | Validasi iss dan aud |
Token tanpa exp | WithExpirationRequired() |
| Rahasia lemah | Minimal 32 byte dari crypto/rand |
| Token dipakai lagi setelah logout | Daftar cabut, atau umur token yang sangat pendek |
Pola access + refresh
Login
├─ access token JWT, 15 menit, TIDAK disimpan di server
└─ refresh token acak 32 byte, 30 hari, DISIMPAN dan bisa dicabut
dikirim sebagai cookie HttpOnly + Secure + SameSite
Permintaan biasa → access token (verifikasi tanda tangan saja)
Access kedaluwarsa → tukar refresh token → access baru + refresh BARU
Logout → hapus refresh token dari database
func (a *Auth) Segarkan(ctx context.Context, refresh string) (string, string, error) {
rt, err := a.simpan.AmbilRefresh(ctx, hash(refresh))
if err != nil || rt.Dicabut || time.Now().After(rt.Kedaluwarsa) {
return "", "", ErrTidakBerhak
}
// ROTASI: refresh token hanya boleh dipakai SEKALI.
// Kalau yang sudah dipakai muncul lagi, itu tanda token dicuri —
// cabut seluruh keluarga token milik pengguna itu.
if rt.SudahDipakai {
_ = a.simpan.CabutSemua(ctx, rt.PenggunaID)
a.log.WarnContext(ctx, "refresh token dipakai ulang",
"pengguna_id", rt.PenggunaID)
return "", "", ErrTidakBerhak
}
_ = a.simpan.TandaiDipakai(ctx, rt.ID)
return a.buatPasangan(ctx, rt.PenggunaID)
}
Rotasi + deteksi pemakaian ulang adalah bagian yang paling sering dilewatkan, dan justru itu yang mengubah refresh token dari "sesi berumur panjang yang tidak bisa dicabut" jadi mekanisme yang bisa mendeteksi pencurian.
Di mana menyimpan token di browser
| Tempat | Risiko |
|---|---|
localStorage | Terbaca JavaScript — satu XSS = token dicuri |
| Variabel JavaScript (memori) | Lebih baik; hilang saat muat ulang halaman |
Cookie HttpOnly | Terbaik — tapi lalu kamu butuh pertahanan CSRF |
Ironi yang layak disadari: begitu kamu menyimpan JWT di cookie HttpOnly — satu-satunya
tempat yang aman dari XSS — kamu sudah punya semua sifat sesi, plus kerugian tidak bisa mencabutnya. Untuk
aplikasi web berbasis browser, itu argumen kuat untuk memakai sesi saja.
Kapan JWT benar-benar tepat
- Antar layanan — layanan B memverifikasi token dari layanan A tanpa memanggil layanan auth.
- Klien mobile — tidak ada cookie; access token pendek + refresh yang bisa dicabut.
- SSO / OIDC —
id_tokenmemang JWT; itu bagian dari standarnya. - Tautan bertanda tangan berumur pendek — verifikasi unggahan, tautan sekali pakai (5 menit).
Latihan: terbitkan JWT HS256 dan tempel di jwt.io — baca payload-nya. Lalu ubah satu karakter
di payload dan buktikan verifikasi gagal. Terakhir, hapus WithValidMethods dari kodemu,
buat token dengan alg: none, dan lihat apakah pustaka yang kamu pakai menerimanya. Apa pun
hasilnya, kembalikan pemeriksaan itu.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.