← Semua pembelajaran / Go Nol → Enterprise
Fase 6 · Keamanan

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 alg dari 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 serverJWT
VerifikasiCari di Redis/DBCek tanda tangan — tanpa I/O
PencabutanSeketikaTidak bisa, sampai kedaluwarsa
Ganti peran/izinBerlaku di permintaan berikutnyaBerlaku setelah token lama habis
Ukuran per permintaan~40 byte300–1000 byte
Cocok untukAplikasi web browserAPI 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
}
KerentananPencegahan
alg: none diterimaDaftar putih algoritma (WithValidMethods)
Kebingungan HS256 / RS256Periksa tipe metode penandatanganan secara eksplisit
Token dari penerbit lain diterimaValidasi iss dan aud
Token tanpa expWithExpirationRequired()
Rahasia lemahMinimal 32 byte dari crypto/rand
Token dipakai lagi setelah logoutDaftar 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

TempatRisiko
localStorageTerbaca JavaScript — satu XSS = token dicuri
Variabel JavaScript (memori)Lebih baik; hilang saat muat ulang halaman
Cookie HttpOnlyTerbaik — 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

  1. Antar layanan — layanan B memverifikasi token dari layanan A tanpa memanggil layanan auth.
  2. Klien mobile — tidak ada cookie; access token pendek + refresh yang bisa dicabut.
  3. SSO / OIDC — id_token memang JWT; itu bagian dari standarnya.
  4. 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.