JWT #
JWT (JSON Web Token) adalah salah satu mekanisme autentikasi yang paling banyak digunakan di API modern — dan juga salah satu yang paling sering diimplementasikan dengan cara yang salah. Banyak engineer mengadopsi JWT hanya karena “semua orang pakai”, tanpa benar-benar memahami trade-off keamanannya: payload yang tidak terenkripsi, token yang tidak bisa dicabut begitu saja, dan risiko yang muncul dari token berumur panjang. Artikel ini membahas JWT dari strukturnya yang fundamental, pilihan algoritma dan implikasinya, access token vs refresh token, cara penyimpanan yang aman, sampai kapan justru session-based auth adalah pilihan yang lebih tepat.
Anatomi JWT #
JWT adalah string berformat header.payload.signature — tiga bagian yang dipisahkan titik, masing-masing di-encode dalam Base64URL.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c3JfMTIzIiwibmFtZSI6IkJ1ZGkgU2FudG9zbyIsInJvbGUiOiJ1c2VyIiwiaXNzIjoiYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6ImFwaS5leGFtcGxlLmNvbSIsImV4cCI6MTcwNjM1NjAwMCwiaWF0IjoxNzA2MzUyNDAwfQ
.
[signature]
Header #
Header berisi metadata tentang token itu sendiri — algoritma signing dan tipe token.
{
"alg": "RS256",
"typ": "JWT"
}
Field alg sangat penting dan akan dibahas mendalam di bagian algoritma. Ini bukan sekadar label — ia menentukan bagaimana signature dibuat dan diverifikasi.
Payload (Claims) #
Payload berisi klaim — pernyataan tentang entitas (biasanya user) dan data tambahan. Ada tiga kategori klaim:
{
"sub": "usr_123", // Registered: subject (siapa user ini)
"iss": "auth.example.com", // Registered: issuer (siapa yang menerbitkan)
"aud": "api.example.com", // Registered: audience (untuk siapa token ini)
"exp": 1706356000, // Registered: expiry (kapan kadaluarsa, Unix timestamp)
"iat": 1706352400, // Registered: issued at (kapan diterbitkan)
"nbf": 1706352400, // Registered: not before (tidak valid sebelum waktu ini)
"jti": "unique-token-id", // Registered: JWT ID (identifier unik untuk revocation)
"name": "Budi Santoso", // Public claim
"email": "[email protected]",
"role": "user", // Private claim (custom aplikasi)
"permissions": ["read:orders", "write:orders"]
}
Payload JWT tidak terenkripsi — ia hanya di-encode dengan Base64URL, bukan dienkripsi. Siapapun yang memegang token bisa membaca isinya dengan mendecode bagian kedua. Jangan pernah menyimpan password, credit card number, atau informasi sensitif lainnya di payload JWT.
Signature #
Signature membuktikan dua hal: bahwa token benar-benar diterbitkan oleh pihak yang mengklaim (integritas issuer), dan bahwa kontennya tidak diubah sejak diterbitkan (integritas data).
// Untuk HMAC (symmetric):
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
// Untuk RSA (asymmetric):
signature = RSA_SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
private_key
)
// Verifikasi menggunakan public key yang berbeda
Pilihan Algoritma — HS256 vs RS256 vs ES256 #
Pilihan algoritma adalah salah satu keputusan terpenting saat mengimplementasikan JWT. Setiap algoritma memiliki trade-off yang berbeda.
flowchart TD
Start["Perlu signing algorithm untuk JWT"]
Q1{"Berapa banyak\nservice yang\nverifikasi token?"}
Q2{"Performance\nadalah constraint\nutama?"}
HS256["HS256\n(HMAC SHA-256)\nSymmetric — satu secret key\nuntuk sign dan verify"]
RS256["RS256\n(RSA SHA-256)\nAsymmetric — private key sign,\npublic key verify"]
ES256["ES256\n(ECDSA SHA-256)\nAsymmetric — lebih kecil dari RSA,\nperforma lebih baik"]
Start --> Q1
Q1 -->|"Hanya satu service\natau trust penuh"| HS256
Q1 -->|"Banyak service\nberbeda"| Q2
Q2 -->|"Key size dan\nperforma penting"| ES256
Q2 -->|"Kompatibilitas\nlebih penting"| RS256
style HS256 fill:#E67E22,color:#fff,stroke:#D35400
style RS256 fill:#27AE60,color:#fff,stroke:#1E8449
style ES256 fill:#2980B9,color:#fff,stroke:#1A5276HS256 — Symmetric, Satu Secret Key #
Signing dan verifikasi menggunakan secret key yang sama. Sederhana tapi berbahaya di lingkungan multi-service.
// ANTI-PATTERN: HS256 di multi-service environment
Auth Service: menerbitkan token dengan secret_key
Order Service: perlu verifikasi → harus punya secret_key yang sama
Payment Service: perlu verifikasi → harus punya secret_key yang sama
→ Jika satu service compromise, semua service bisa menerbitkan token valid
→ Secret key harus didistribusikan ke semua service
// BENAR: HS256 hanya untuk aplikasi single-service atau monolith
Auth Service → issue token
API Service (sama deployment) → verifikasi menggunakan secret yang sama
→ Tidak perlu mendistribusikan secret ke pihak lain
RS256 — Asymmetric, Private/Public Key Pair #
Auth service menerbitkan token menggunakan private key, service lain memverifikasi menggunakan public key. Public key bisa dibagikan bebas — hanya private key yang harus dijaga.
// BENAR: RS256 untuk multi-service
Auth Service: sign menggunakan private_key (disimpan aman, tidak dibagikan)
Order Service: verifikasi menggunakan public_key (bisa diakses oleh siapapun)
Payment Service: verifikasi menggunakan public_key
Analytics Service: verifikasi menggunakan public_key
→ Compromise di satu service tidak memungkinkan mereka menerbitkan token valid
→ Hanya Auth Service yang bisa issue token
// Cara distribusi public key yang umum:
Auth Service expose endpoint: GET /.well-known/jwks.json
→ Service lain fetch public key dari sini
→ Key rotation bisa dilakukan tanpa koordinasi manual
ES256 — Asymmetric, ECDSA #
Seperti RS256 tapi menggunakan Elliptic Curve Cryptography. Token yang dihasilkan lebih kecil (~60% lebih kecil dari RSA) dan operasinya lebih cepat, tapi kompatibilitasnya dengan library lama lebih terbatas.
Perbandingan ukuran signature:
HS256: ~43 bytes (Base64URL)
RS256: ~342 bytes (Base64URL) — RSA signature besar
ES256: ~86 bytes (Base64URL) — jauh lebih kecil dari RSA
Untuk API yang menerima jutaan request per hari dengan JWT di setiap header,
perbedaan ukuran ini berarti penghematan bandwidth yang signifikan.
Cara Kerja JWT dalam Autentikasi #
sequenceDiagram
participant U as User / Client
participant AS as Auth Service
participant API as API Service
participant DB as Database
Note over U,DB: Login dan dapatkan token
U->>AS: POST /auth/login { email, password }
AS->>DB: Verifikasi kredensial
DB-->>AS: User data
AS-->>U: { access_token (15 min), refresh_token (7 hari) }
Note over U,API: Request dengan access token
U->>API: GET /api/orders\nAuthorization: Bearer <access_token>
API->>API: Verifikasi signature JWT\n(tidak perlu database call)
API-->>U: Response data
Note over U,AS: Access token expired — gunakan refresh token
U->>AS: POST /auth/refresh\n{ refresh_token }
AS->>DB: Verifikasi refresh token (masih valid?)
DB-->>AS: Valid
AS-->>U: { access_token baru (15 min), refresh_token baru (7 hari) }
Note over U,AS: Logout — invalidasi refresh token
U->>AS: POST /auth/logout\n{ refresh_token }
AS->>DB: Hapus/invalidasi refresh token
DB-->>AS: Done
AS-->>U: 200 OKKeunggulan utama JWT yang terlihat dari diagram ini: API Service tidak perlu database call untuk setiap request. Ia memverifikasi signature secara lokal menggunakan public key — jauh lebih cepat dari session-based auth yang perlu query ke session store.
Access Token dan Refresh Token #
Memisahkan access token dan refresh token adalah fondasi dari implementasi JWT yang aman. Ini bukan sekadar best practice — ini adalah kebutuhan karena JWT tidak bisa dicabut begitu saja.
Access Token:
Fungsi: membawa identitas dan izin untuk setiap API request
Durasi: PENDEK — 5 sampai 15 menit
Disimpan: di memory (JavaScript variable, bukan localStorage)
Alasan durasi pendek: jika bocor, window of attack terbatas
Refresh Token:
Fungsi: mendapatkan access token baru setelah expired
Durasi: PANJANG — 7 hari sampai 30 hari (atau sampai logout)
Disimpan: HttpOnly Secure Cookie (tidak bisa diakses JavaScript)
Alasan: dicuri via XSS tidak memungkinkan karena HttpOnly
// ANTI-PATTERN: Access token berumur panjang
{
"sub": "usr_123",
"exp": 1738887600 // kadaluarsa 30 hari lagi
}
→ Jika token bocor, attacker punya akses 30 hari
→ Tidak ada cara untuk mencabut akses lebih cepat
// BENAR: Access token pendek + refresh token
Access token: exp = sekarang + 15 menit
Refresh token: disimpan di DB, bisa dicabut kapan saja (logout, suspicious activity)
→ Jika access token bocor, hanya 15 menit window
→ Jika refresh token bocor, langsung invalidasi di DB
Token Rotation — Mencegah Refresh Token Theft #
Token rotation adalah strategi di mana setiap kali refresh token digunakan, ia diganti dengan yang baru. Jika refresh token yang lama digunakan lagi (oleh attacker yang mencurinya), sistem mendeteksi bahwa ada penggunaan ganda dan invalidasi semua token untuk user tersebut.
sequenceDiagram
participant U as User (Legit)
participant A as Attacker
participant AS as Auth Service
Note over A: Attacker berhasil mencuri refresh_token_v1
U->>AS: Gunakan refresh_token_v1
AS-->>U: access_token baru + refresh_token_v2
Note over AS: refresh_token_v1 diinvalidasi
A->>AS: Coba gunakan refresh_token_v1 (sudah dicuri)
AS->>AS: Deteksi: token sudah pernah dipakai!\nKemungkinan token theft!
AS->>AS: Invalidasi SEMUA refresh token user ini
AS-->>A: 401 Unauthorized
Note over U: User diforce logout dari semua device
U->>U: Harus login ulang
Note over U: Aman — attacker tidak bisa dapat access baruImplementasi token rotation:
1. Setiap refresh token memiliki ID unik dan disimpan di database
2. Saat refresh token digunakan:
a. Tandai token lama sebagai "used"
b. Terbitkan access token baru + refresh token baru
3. Jika refresh token yang sudah "used" digunakan lagi:
a. Invalidasi SEMUA refresh token aktif user ini
b. Log sebagai security event
c. Opsional: kirim notifikasi ke user
Revocation — Cara Mencabut JWT yang Sudah Diterbitkan #
Ini adalah salah satu keterbatasan fundamental JWT: access token tidak bisa dicabut sebelum expired. Kamu harus memitigasi ini dengan kombinasi durasi pendek dan beberapa strategi revocation.
Strategi 1: Durasi access token sangat pendek (direkomendasikan)
Access token: 5–15 menit
→ Jika bocor, window of attack sangat kecil
→ Tidak perlu revocation untuk mayoritas use case
Strategi 2: Token blacklist (jika perlu immediate revocation)
Simpan JTI (JWT ID) yang sudah dicabut di Redis/cache
Setiap request: cek apakah JTI ada di blacklist
Bersihkan blacklist entry setelah token expired secara natural
// Blacklist check di middleware
func verifyJWT(tokenString string) (*Claims, error) {
claims, err := parseJWT(tokenString)
if err != nil {
return nil, err
}
// Cek blacklist
if isBlacklisted(claims.JTI) {
return nil, ErrTokenRevoked
}
return claims, nil
}
Kelemahan: setiap request perlu query Redis
→ Menghilangkan salah satu keunggulan utama JWT (stateless)
→ Untuk use case yang butuh immediate revocation, pertimbangkan session
Strategi 3: Refresh token invalidation (untuk logout)
Logout = hapus refresh token dari DB
Access token lama masih valid sampai expired (5–15 menit)
→ Acceptable untuk kebanyakan kasus
Menyimpan Token dengan Aman #
Di mana token disimpan menentukan jenis serangan apa yang mungkin. Ini adalah keputusan yang sering diremehkan.
Opsi penyimpanan dan risikonya:
localStorage / sessionStorage:
Risiko: SANGAT BERBAHAYA untuk refresh token
Masalah: bisa diakses JavaScript → rentan XSS
Jika ada XSS vulnerability, attacker bisa steal token
→ JANGAN simpan refresh token di localStorage
Untuk access token yang berumur sangat pendek (< 5 menit):
→ Acceptable karena window of attack kecil
→ Tapi tetap lebih baik di memory
Memory (JavaScript variable):
Risiko: RENDAH
Kelemahan: hilang saat halaman di-refresh
→ Ideal untuk access token
→ Tidak persistent, tapi itu yang kita mau
HttpOnly Secure Cookie:
Risiko: SANGAT RENDAH untuk XSS (JavaScript tidak bisa baca)
Perlu proteksi CSRF (gunakan SameSite=Strict atau CSRF token)
→ Ideal untuk refresh token di web app
Secure Storage (Mobile):
iOS: Keychain
Android: Keystore
→ Gunakan untuk semua token di mobile app
// Konfigurasi cookie yang aman untuk refresh token (server-side)
Set-Cookie: refresh_token=<value>;
HttpOnly; // tidak bisa diakses JavaScript
Secure; // hanya dikirim via HTTPS
SameSite=Strict; // tidak dikirim di cross-site request (proteksi CSRF)
Path=/auth; // hanya dikirim ke endpoint /auth/*
Max-Age=604800; // 7 hari
Validasi Klaim yang Ketat #
Verifikasi signature saja tidak cukup. Klaim dalam payload harus juga divalidasi dengan ketat.
// Contoh validasi JWT yang lengkap di Go
func validateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{},
func(token *jwt.Token) (interface{}, error) {
// 1. Validasi algoritma — WAJIB
// Mencegah "algorithm confusion attack" (none algorithm)
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v",
token.Header["alg"])
}
return publicKey, nil
},
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
// 2. Validasi exp — library biasanya sudah cek ini
if claims.ExpiresAt.Before(time.Now()) {
return nil, ErrTokenExpired
}
// 3. Validasi issuer — WAJIB
if claims.Issuer != "auth.example.com" {
return nil, ErrInvalidIssuer
}
// 4. Validasi audience — WAJIB jika ada multiple service
if !claims.VerifyAudience("api.example.com", true) {
return nil, ErrInvalidAudience
}
// 5. Validasi nbf (not before) — opsional tapi recommended
if claims.NotBefore != nil && time.Now().Before(claims.NotBefore.Time) {
return nil, ErrTokenNotYetValid
}
return claims, nil
}
Algorithm Confusion Attack adalah serangan nyata yang sudah menyebabkan banyak security breach. Attacker mengubahalgdi header menjadi"none"dan menghapus signature — jika server tidak memvalidasi algoritma dengan ketat, token tanpa signature diterima sebagai valid. Selalu whitelist algoritma yang diizinkan dan tolak token dengan algoritma yang tidak dikenal atau"none".
JWT vs Session-Based Auth — Kapan Memilih Mana #
JWT dan session-based auth bukan saingan — mereka trade-off yang berbeda untuk kebutuhan yang berbeda.
flowchart TD
Start["Butuh mekanisme\nautentikasi"]
Q1{"Perlu immediate\nrevocation?\n(force logout, ban user)"}
Q2{"Apakah sistem\ndistributed?\n(multiple service)"}
Q3{"Skala besar?\nPerlu stateless?"}
Session["Session-Based Auth\n→ Mudah revoke\n→ Sederhana untuk monolith\n→ Butuh shared session store\n jika distributed"]
JWT["JWT\n→ Stateless, scalable\n→ Self-contained\n→ Revocation butuh workaround"]
Both["Hybrid:\nJWT access token (pendek)\n+ Session untuk revocation\natau opaque refresh token"]
Start --> Q1
Q1 -->|"Ya, harus bisa\nlangsung revoke"| Q2
Q1 -->|"Tidak, 15 menit\nwindow oke"| JWT
Q2 -->|"Ya, microservices"| JWT
Q2 -->|"Tidak, monolith"| Session
JWT --> Q3
Q3 -->|"Perlu immediate\nrevocation juga"| Both
style Session fill:#27AE60,color:#fff
style JWT fill:#2980B9,color:#fff
style Both fill:#8E44AD,color:#fffGunakan JWT jika:
✓ API yang dikonsumsi banyak service berbeda
✓ Mobile app (tidak bisa manage server-side session dengan mudah)
✓ Sistem distributes atau microservices
✓ SSO (Single Sign-On) antar multiple app
✓ Statelessness adalah requirement (cloud-native, serverless)
Gunakan Session jika:
✓ Aplikasi web monolith yang sederhana
✓ Perlu immediate revocation (force logout, user ban)
✓ Tim belum familiar dengan JWT security pitfalls
✓ Tidak ada kebutuhan cross-service authentication
Kesalahan Implementasi yang Paling Umum #
// ✗ Kesalahan 1: Simpan refresh token di localStorage
localStorage.setItem('refresh_token', token)
// Rentan XSS — attacker bisa steal token dengan inject script
// ✓ Solusi: HttpOnly cookie untuk refresh token, memory untuk access token
---
// ✗ Kesalahan 2: Access token berumur panjang
{ "exp": now + 30 hari }
// Jika bocor, attacker punya akses 30 hari tanpa cara mencabutnya
// ✓ Solusi: Access token 5-15 menit, refresh token di HttpOnly cookie
---
// ✗ Kesalahan 3: Tidak memvalidasi algoritma
jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil // tidak cek algorithm!
})
// Rentan algorithm confusion attack (alg: "none")
// ✓ Solusi: Selalu whitelist algoritma yang diizinkan
---
// ✗ Kesalahan 4: Simpan data sensitif di payload
{
"sub": "usr_123",
"password_hash": "$2b$10$...", // JANGAN
"credit_card": "4111111111111111" // JANGAN
}
// Payload bisa dibaca siapapun yang punya token
// ✓ Solusi: Hanya simpan identifier dan klaim non-sensitif
---
// ✗ Kesalahan 5: Tidak validasi iss dan aud
// Token dari service lain atau attacker diterima jika signature valid
// ✓ Solusi: Selalu validasi issuer dan audience secara eksplisit
---
// ✗ Kesalahan 6: HS256 untuk multi-service
// Semua service perlu secret key yang sama
// Satu service compromise = semua service compromise
// ✓ Solusi: RS256 atau ES256 untuk multi-service
Checklist Keamanan JWT #
TOKEN DESIGN:
□ Access token berumur pendek (maksimal 15 menit untuk sensitive API)
□ Refresh token berumur lebih panjang tapi bisa dicabut
□ JTI (JWT ID) ada di setiap token untuk tracking
□ Klaim iss dan aud terisi dengan nilai yang spesifik
ALGORITMA:
□ RS256 atau ES256 untuk multi-service (bukan HS256)
□ Private key disimpan aman, tidak hardcode di kode
□ Public key didistribusikan via JWKS endpoint
□ Key rotation dijadwalkan secara berkala
VALIDASI DI SERVER:
□ Algoritma divalidasi secara eksplisit (whitelist, tolak "none")
□ exp divalidasi (library biasanya sudah handle ini)
□ iss divalidasi dengan nilai yang hardcode di konfigurasi
□ aud divalidasi dengan nilai yang hardcode di konfigurasi
□ nbf divalidasi jika ada
PENYIMPANAN DI CLIENT:
□ Refresh token di HttpOnly + Secure + SameSite cookie
□ Access token di memory (bukan localStorage)
□ Mobile app menggunakan secure storage (Keychain/Keystore)
REFRESH TOKEN:
□ Token rotation diimplementasikan
□ Reuse detection ada (dan invalidate semua token user jika terdeteksi)
□ Refresh token bisa dicabut lewat logout endpoint
□ Refresh token disimpan di database untuk tracking
KEAMANAN TAMBAHAN:
□ HTTPS wajib di semua environment (bukan hanya production)
□ Tidak ada informasi sensitif di payload
□ Rate limiting di endpoint login dan refresh
□ Logging setiap event: login, refresh, logout, failed attempts
Ringkasan #
- Payload JWT tidak terenkripsi — ia hanya Base64URL encoded. Siapapun yang punya token bisa membaca isinya. Jangan pernah simpan password, credit card, atau data sensitif di payload.
- Pilih RS256 atau ES256 untuk multi-service, HS256 hanya untuk single-service — asymmetric signing memungkinkan service lain memverifikasi token tanpa memiliki kemampuan menerbitkan token baru.
- Access token harus berumur pendek, refresh token di HttpOnly cookie — access token 5–15 menit di memory, refresh token di HttpOnly Secure SameSite cookie. Ini adalah kombinasi yang melindungi dari XSS dan CSRF sekaligus.
- Token rotation adalah defense in depth untuk refresh token theft — setiap penggunaan refresh token menghasilkan token baru, dan penggunaan token lama mendeteksi kemungkinan theft dan trigger invalidasi menyeluruh.
- JWT tidak bisa dicabut secara langsung — mitigasi dengan durasi pendek, refresh token yang bisa dicabut, atau blacklist berbasis JTI jika perlu immediate revocation. Jika immediate revocation adalah requirement utama, pertimbangkan session-based auth.
- Algorithm confusion attack nyata dan berbahaya — selalu whitelist algoritma yang diizinkan dan tolak token dengan
alg: "none"atau algoritma yang tidak dikenal.- Validasi lebih dari sekadar signature — validasi
exp,iss,aud, dannbfsecara eksplisit. Token yang valid secara signature tapi dari issuer yang salah tetap berbahaya.- Session-based auth bukan kuno — untuk aplikasi monolith yang butuh immediate revocation dan tidak perlu cross-service auth, session lebih sederhana dan lebih aman dari JWT yang diimplementasikan dengan buruk.
- Rate limit semua endpoint autentikasi — endpoint login, refresh, dan reset password adalah target brute force. Rate limiting adalah pertahanan minimal.
- Log setiap event autentikasi — login sukses, login gagal, refresh token, logout, dan anomali seperti reuse detection. Audit trail yang baik adalah fondasi incident response yang efektif.