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 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:#1A5276

HS256 — 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 OK

Keunggulan 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 baru
Implementasi 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 mengubah alg di 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:#fff
Gunakan 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, dan nbf secara 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.

← Sebelumnya: gRPC   Berikutnya: OAuth →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact