Stateless Tapi Tetap Bisa Dicabut: Revoke JWT yang Bocor
11 min read

Stateless Tapi Tetap Bisa Dicabut: Revoke JWT yang Bocor

Bayangkan skenario ini: sistem monitoring mendeteksi JWT seorang user bocor lewat log yang salah konfigurasi, atau lewat XSS di frontend. Token itu masih valid 30 menit lagi. Masalahnya, JWT dirancang untuk stateless — begitu diterbitkan, server tidak “mengingat” token itu, dan tidak ada mekanisme bawaan untuk mencabutnya sebelum exp tercapai. Artikel ini membahas beberapa strategi praktis untuk membunuh JWT yang sudah beredar, dari yang paling ringan sampai opsi nuklir, lengkap dengan pertimbangan keamanan yang sering terlewat: siapa saja yang bisa melihat isi token saat kamu membangun mekanisme revocation-nya sendiri.

Kenapa JWT Sulit Di-revoke

JWT (JSON Web Token) dirancang supaya server tidak perlu query database setiap kali memverifikasi request. Semua informasi yang dibutuhkan — user ID, role, waktu expired — sudah ada di dalam token itu sendiri, ditandatangani secara digital. Server cukup verifikasi signature, tidak perlu tanya siapapun.

Ini bagus untuk performa dan skalabilitas: layanan bisa scale horizontal tanpa shared session store. Tapi trade-off-nya jelas — begitu token diterbitkan dan ditandatangani, server “kehilangan kendali” atasnya sampai token itu expired dengan sendirinya. Tidak ada tombol “cabut token ini” secara default, karena verifikasi tidak pernah menyentuh database.

sequenceDiagram
    participant Client
    participant API
    participant AuthService

    Client->>API: Request + JWT
    API->>API: Verify signature (tanpa DB lookup)
    API->>API: Cek claim exp, cek permission
    API-->>Client: Response

    Note over API: Tidak ada langkah "cek status token di DB"

Bandingkan dengan session-based auth tradisional, di mana server menyimpan session ID di database atau Redis. Revoke session semudah menghapus baris itu — request berikutnya langsung ditolak. JWT tidak punya keuntungan ini secara alami. Semua strategi revoke JWT pada dasarnya adalah cara menambahkan kembali sedikit “state” ke sistem yang awalnya didesain stateless.


Access Token vs Refresh Token — Singkat

Sebelum masuk ke strategi, penting memahami dua jenis token yang biasanya dipakai bersamaan:

Access token — token berumur pendek (biasanya 15–60 menit), dikirim di setiap request ke API, dan inilah yang diverifikasi tanpa DB lookup seperti dijelaskan di atas. Karena umurnya pendek, dampak dari token yang bocor secara alami terbatas oleh waktu.

Refresh token — token berumur panjang (hari sampai minggu), disimpan di tempat yang lebih aman (httpOnly cookie, secure storage), dan cuma dipakai untuk menukar access token baru saat yang lama expired. Refresh token hampir selalu disimpan statusnya di database, karena ini titik di mana sistem butuh kontrol jangka panjang — logout, deteksi reuse, revoke akun.

flowchart LR
    A[Login] --> B[Access Token 15 menit]
    A --> C[Refresh Token 7 hari]
    B -->|expired| D{Refresh Token valid?}
    D -- Ya --> E[Access Token baru]
    D -- Tidak --> F[Wajib login ulang]

Poin krusialnya: me-revoke refresh token tidak otomatis membunuh access token yang masih hidup. Kalau access token dicuri dan masih punya sisa 30 menit, mencabut refresh token di database tidak berpengaruh apa-apa sampai access token itu sendiri kedaluwarsa — karena verifikasi access token tidak pernah menyentuh tabel refresh token. Ini kesalahpahaman umum yang membuat tim mengira sistem mereka sudah aman padahal celahnya masih terbuka selama sisa umur access token.

  • Revoke refresh token mencegah token baru diterbitkan, tapi tidak membunuh access token yang sudah beredar.
  • Kalau access token yang dicuri, kamu butuh mekanisme terpisah untuk mematikannya lebih cepat dari exp.
  • Selalu asumsikan penyerang bisa memakai access token curian sampai detik terakhir masa berlakunya, kecuali ada mekanisme aktif yang menghentikannya.

Strategi 1: Denylist Berbasis jti

Cara paling umum dan paling murah untuk membunuh access token spesifik adalah dengan denylist (blocklist). Idenya sederhana: simpan identitas token yang mau dicabut di store cepat seperti Redis, lalu cek keberadaannya di setiap request.

Kuncinya ada di claim jti (JWT ID) — sebuah UUID unik yang diselipkan saat token diterbitkan, khusus untuk mengidentifikasi token itu tanpa membawa data sensitif apapun.

// Struktur claim JWT dengan jti
type AccessTokenClaims struct {
	UserID string `json:"sub"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(userID, role string, secret []byte) (string, error) {
	claims := AccessTokenClaims{
		UserID: userID,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			ID:        uuid.NewString(), // ini yang jadi jti
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

Saat token perlu dicabut — misalnya setelah terdeteksi bocor — simpan jti-nya di Redis dengan TTL yang sama persis dengan sisa waktu hidup token. Ini penting supaya entry di Redis otomatis hilang begitu token itu sendiri sudah kedaluwarsa, tidak perlu proses cleanup manual.

func RevokeToken(ctx context.Context, rdb *redis.Client, claims *AccessTokenClaims) error {
	remaining := time.Until(claims.ExpiresAt.Time)
	if remaining <= 0 {
		return nil // sudah expired, tidak perlu didenylist
	}

	key := fmt.Sprintf("denylist:jti:%s", claims.ID)
	return rdb.Set(ctx, key, 1, remaining).Err()
}

Middleware verifikasi kemudian menambah satu langkah: setelah signature valid, cek apakah jti-nya ada di denylist.

func AuthMiddleware(rdb *redis.Client, secret []byte) gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenStr := extractBearerToken(c)

		claims := &AccessTokenClaims{}
		_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
			return secret, nil
		})
		if err != nil {
			c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
			return
		}

		// ANTI-PATTERN: langsung percaya token setelah signature valid
		// c.Next()

		// BENAR: cek denylist sebelum melanjutkan request
		key := fmt.Sprintf("denylist:jti:%s", claims.ID)
		exists, err := rdb.Exists(c.Request.Context(), key).Result()
		if err != nil {
			c.AbortWithStatusJSON(500, gin.H{"error": "internal error"})
			return
		}
		if exists > 0 {
			c.AbortWithStatusJSON(401, gin.H{"error": "token revoked"})
			return
		}

		c.Set("userID", claims.UserID)
		c.Next()
	}
}

Pendekatan ini menambah satu round-trip ke Redis di setiap request. Untuk sebagian besar aplikasi, latency tambahan ini (biasanya di bawah 1ms untuk Redis lokal/regional) jauh lebih murah dibanding risiko token bocor tetap valid.

Kalau kamu khawatir soal biaya lookup di setiap request, cache hasil “token bersih” ini di memory lokal dengan TTL pendek (misalnya 5 detik), supaya tidak setiap request harus round-trip ke Redis. Ini trade-off kecil antara kecepatan revoke dan efisiensi — 5 detik delay masih jauh lebih baik daripada 30 menit.

Strategi 2: Token Version / Security Stamp

Denylist berbasis jti cocok untuk mencabut satu token spesifik. Tapi kalau skenarionya adalah “akun user ini dicurigai kompromis, matikan semua sesi aktifnya”, pendekatan yang lebih tepat adalah token versioning.

Caranya: simpan sebuah angka versi (token_version) di record user pada database, lalu selipkan angka itu sebagai claim di setiap access token yang diterbitkan untuk user tersebut.

type AccessTokenClaims struct {
	UserID       string `json:"sub"`
	TokenVersion int    `json:"tver"`
	jwt.RegisteredClaims
}

func GenerateAccessToken(user *User, secret []byte) (string, error) {
	claims := AccessTokenClaims{
		UserID:       user.ID,
		TokenVersion: user.TokenVersion, // diambil dari DB saat login
		RegisteredClaims: jwt.RegisteredClaims{
			ID:        uuid.NewString(),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

Untuk mematikan seluruh token milik user, cukup increment token_version di database:

func RevokeAllUserTokens(ctx context.Context, db *sql.DB, userID string) error {
	_, err := db.ExecContext(ctx,
		`UPDATE users SET token_version = token_version + 1 WHERE id = $1`,
		userID,
	)
	return err
}

Middleware perlu membandingkan tver di token dengan nilai terkini di database (atau cache):

func AuthMiddlewareWithVersion(userStore UserStore, secret []byte) gin.HandlerFunc {
	return func(c *gin.Context) {
		claims := &AccessTokenClaims{}
		// ... parsing token seperti sebelumnya ...

		currentVersion, err := userStore.GetTokenVersion(c.Request.Context(), claims.UserID)
		if err != nil {
			c.AbortWithStatusJSON(500, gin.H{"error": "internal error"})
			return
		}

		// ANTI-PATTERN: tidak mengecek versi sama sekali
		// if false { ... }

		// BENAR: token dengan versi lama otomatis tertolak
		if claims.TokenVersion != currentVersion {
			c.AbortWithStatusJSON(401, gin.H{"error": "session invalidated"})
			return
		}

		c.Next()
	}
}

Bedanya dengan denylist per-jti: strategi ini membunuh semua token yang diterbitkan sebelum increment terjadi, tanpa perlu tahu jti masing-masing token satu per satu. Cocok untuk kasus “user ganti password”, “akun dicurigai kompromis secara luas”, atau tombol “logout dari semua perangkat”.

Kekurangannya, GetTokenVersion butuh lookup ke database atau cache di setiap request — mirip biaya dengan denylist, tapi granularitasnya per-user, bukan per-token. Banyak sistem produksi memakai kombinasi keduanya: token version untuk kasus akun-lebar, denylist jti untuk kasus token spesifik yang diketahui bocor.


Strategi 3: Refresh Token Revocation

Untuk refresh token, revocation jauh lebih sederhana karena refresh token memang didesain untuk divalidasi lewat database di setiap penggunaannya — beda dengan access token yang divalidasi tanpa DB lookup.

type RefreshToken struct {
	ID        string
	UserID    string
	TokenHash string // simpan hash, bukan raw token
	ExpiresAt time.Time
	Revoked   bool
}

func RevokeRefreshToken(ctx context.Context, db *sql.DB, tokenID string) error {
	_, err := db.ExecContext(ctx,
		`UPDATE refresh_tokens SET revoked = true WHERE id = $1`,
		tokenID,
	)
	return err
}

func ValidateRefreshToken(ctx context.Context, db *sql.DB, rawToken string) (*RefreshToken, error) {
	hash := sha256.Sum256([]byte(rawToken))
	var rt RefreshToken
	err := db.QueryRowContext(ctx,
		`SELECT id, user_id, expires_at, revoked FROM refresh_tokens
		 WHERE token_hash = $1`,
		hex.EncodeToString(hash[:]),
	).Scan(&rt.ID, &rt.UserID, &rt.ExpiresAt, &rt.Revoked)
	if err != nil {
		return nil, err
	}
	if rt.Revoked || time.Now().After(rt.ExpiresAt) {
		return nil, errors.New("refresh token invalid")
	}
	return &rt, nil
}

Seperti disebutkan di bagian awal, revoke refresh token mencegah penerbitan access token baru, tapi access token yang sudah ada di tangan penyerang tetap hidup sampai exp-nya sendiri. Karena itu, kalau insiden yang terjadi adalah access token bocor (bukan refresh token), strategi 1 atau 2 tetap wajib dijalankan bersamaan. Revoke refresh token saja tanpa strategi lain memberi rasa aman yang salah.

Jangan simpan refresh token dalam bentuk plaintext di database. Kalau database bocor, penyerang mendapat token siap pakai untuk semua user. Simpan hash-nya (SHA-256 sudah cukup untuk kasus ini karena refresh token sudah random dan panjang), lalu bandingkan hash saat validasi.

Strategi 4: Rotasi Signing Key

Ini opsi paling ekstrem: mengganti secret atau key pair yang dipakai untuk menandatangani JWT. Begitu key diganti, semua token yang pernah diterbitkan dengan key lama langsung gagal verifikasi signature — termasuk token milik user yang sama sekali tidak terlibat insiden.

// Rotasi key biasanya melibatkan periode transisi
// dengan multiple valid keys, diidentifikasi lewat header "kid"

func VerifyWithKeyRotation(tokenStr string, keyStore KeyStore) (*jwt.Token, error) {
	return jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
		kid, ok := t.Header["kid"].(string)
		if !ok {
			return nil, errors.New("missing kid header")
		}
		// Cari public key berdasarkan kid — key lama yang sudah
		// dicabut tidak akan ditemukan di keyStore lagi
		key, err := keyStore.GetKey(kid)
		if err != nil {
			return nil, errors.New("key revoked or unknown")
		}
		return key, nil
	})
}

Rotasi key masuk akal saat skenarionya adalah kebocoran signing secret itu sendiri, atau breach besar yang mencurigai banyak token sekaligus. Untuk satu token yang dicuri lewat XSS atau log yang salah konfigurasi, rotasi key adalah overkill — dampaknya memaksa seluruh user aktif untuk login ulang, bukan cuma satu akun yang terdampak.


Perbandingan Strategi

StrategiKecepatan EfektifBlast RadiusKompleksitas
Denylist jtiInstanSatu token spesifikRendah — butuh Redis + 1 field claim
Token VersionInstanSemua token satu userSedang — butuh kolom DB + lookup per-request
Refresh Token RevocationInstan untuk token baru, tidak berdampak ke access token lamaSatu user (token masa depan)Rendah — natural karena refresh token sudah divalidasi via DB
Rotasi Signing KeyInstanSeluruh user aktifTinggi — butuh strategi transisi key, koordinasi deployment

Kombinasi yang umum dipakai di produksi: denylist jti untuk kasus token individual bocor, token version untuk tombol “logout semua perangkat” atau insiden akun, dan rotasi key disimpan sebagai prosedur darurat kalau signing secret sendiri yang bocor.


Mengamankan Denylist dari Internal Team

Ada satu jebakan yang sering terlewat saat membangun mekanisme revocation: apa yang kamu simpan di dalam denylist itu sendiri. Kalau kamu menyimpan raw JWT sebagai key atau value di Redis, siapapun yang punya akses ke Redis — tim ops, layanan lain yang share instance yang sama, bahkan dashboard monitoring — bisa membaca seluruh isi claim token itu, dan dalam kasus terburuk, memakainya untuk request selama token masih valid.

// ANTI-PATTERN: menyimpan raw token sebagai key
key := fmt.Sprintf("denylist:%s", rawTokenString)
rdb.Set(ctx, key, 1, remaining)
// Siapapun yang bisa baca Redis, bisa lihat isi JWT ini utuh

// BENAR: simpan jti saja, bukan token itu sendiri
key := fmt.Sprintf("denylist:jti:%s", claims.ID)
rdb.Set(ctx, key, 1, remaining)
// jti cuma UUID acak, tidak membawa informasi apapun tentang user

Kalau sistemmu belum punya claim jti sama sekali dan terpaksa mengidentifikasi token lewat isinya sendiri, jangan simpan raw token — simpan hash satu arahnya:

func denylistKeyFromToken(rawToken string) string {
	hash := sha256.Sum256([]byte(rawToken))
	return fmt.Sprintf("denylist:hash:%s", hex.EncodeToString(hash[:]))
}

Hash tidak bisa dibalik untuk mendapatkan token asli, tapi tetap bisa dicocokkan — kamu hash token yang masuk, lalu bandingkan dengan key yang tersimpan.

Beberapa langkah tambahan yang sepadan diterapkan:

  • Batasi akses ke store denylist lewat ACL. Redis mendukung ACL per-user; pastikan cuma service auth yang boleh baca/tulis namespace denylist:*, bukan seluruh tim internal atau service lain yang kebetulan share instance Redis yang sama.
  • Jangan pernah log raw token. Ini kebocoran paling umum yang justru bukan dari mekanisme denylist itu sendiri — melainkan dari access log, APM tracing, atau error reporting yang tanpa sengaja mencetak header Authorization secara utuh.
  • Redact token di observability tools. Kalau pakai APM seperti Datadog atau New Relic, pastikan middleware auth tidak meneruskan header Authorization ke span attributes tanpa redaction.
Mekanisme revocation yang dibangun dengan baik tapi menyimpan raw token di tempat yang bisa diakses banyak orang, secara efektif menciptakan celah baru: kamu berhasil mematikan token dari sisi verifikasi normal, tapi membuka jalur baru bagi siapapun yang punya akses ke store itu untuk melihat atau bahkan menyalahgunakan token yang sama sebelum revocation-nya diproses.

Ringkasan

  • JWT stateless secara desain — tidak ada tombol revoke bawaan, semua strategi revocation pada dasarnya menambahkan kembali sedikit state ke sistem.
  • Denylist jti adalah cara paling murah untuk membunuh satu token spesifik, dengan TTL menyesuaikan sisa umur token supaya self-cleaning.
  • Token version cocok untuk mematikan semua token milik satu user sekaligus — dipakai untuk kasus “logout semua perangkat” atau akun dicurigai kompromis.
  • Revoke refresh token mencegah penerbitan access token baru, tapi tidak membunuh access token yang sudah beredar dan masih dalam masa berlakunya.
  • Rotasi signing key adalah opsi darurat yang mematikan semua token semua user — hanya masuk akal kalau signing secret itu sendiri yang bocor.
  • Jangan simpan raw JWT di dalam mekanisme denylist — simpan jti atau hash token, batasi akses lewat ACL, dan jangan pernah log token mentah di access log atau APM.
  • Kombinasi paling umum di produksi: denylist jti untuk insiden token individual, token version untuk insiden akun, rotasi key disimpan sebagai prosedur darurat breach besar.

Portofolio