Verifikasi JSON Web Token (JWT) berbasis asymmetric key (seperti RS256 atau ES256) membutuhkan public key dari Identity Provider (IdP) melalui endpoint JSON Web Key Set (JWKS). Mengambil JWKS via HTTP call pada setiap request API memicu latensi tinggi dan pemborosan bandwidth. Sebaliknya, caching JWKS tanpa strategi refresh dinamis menyebabkan autentikasi gagal total saat IdP melakukan rotasi kunci kriptografi (key rotation).

Dilema JWKS: Per-Request Latency vs Cache Stale

Dua pendekatan ekstrem yang sering menjadi jebakan arsitektural pada API gateway atau microservice Go:

  • Fetch Per-Request: Setiap request validasi JWT memicu HTTP GET ke endpoint /.well-known/jwks.json milik IdP (Auth0, Keycloak, Okta, Firebase). Latensi auth bertambah 50–300ms per request. Jika IdP mengalami rate limiting atau degradasi jaringan, seluruh API ikut lumpuh.
  • Static/Naive In-Memory Caching: JWKS di-fetch sekali saat aplikasi start atau di-cache dengan TTL panjang tanpa mekanisme invalidasi. Ketika IdP memutar asymmetric key (terjadwal maupun darurat akibat kebocoran private key), token baru dikirim dengan Header Key ID (kid) baru. Server menolak token tersebut (401 Unauthorized) karena kid baru belum terdaftar di cache.

Aturan Kunci: Cache JWKS harus bersifat dinamis. Cache disimpan di memory dengan background refresh, namun wajib menyediakan fallback on-demand refresh ketika menerima kid yang belum dikenali.

Arsitektur Solusi: Hybrid In-Memory Cache

Solusi yang tangguh menggabungkan tiga pilar arsitektur:

  1. In-Memory Cache dengan TTL: Public keys disimpan di memori aplikasi untuk validasi sub-milidetik tanpa round-trip I/O.
  2. On-Demand Refresh saat KID Hilang: Jika incoming JWT memiliki kid yang tidak ada di cache, sistem mengasumsikan rotasi kunci telah terjadi dan memicu fetch JWKS ulang ke IdP secara sinkron sebelum memvalidasi token.
  3. Rate-Limited Refresh: Attacker dapat mengeksploitasi on-demand refresh dengan mengirim ribuan request berisi random kid (Cache Snooping / Denial of Service terhadap IdP). Rate limiter membatasi frekuensi refresh JWKS maksimum (misal: 1 kali per 1–5 menit jika ada miss berturut-turut).

Implementasi Middleware Go Fiber

Gunakan pustaka standar github.com/MicahParks/keyfunc/v2 bersama github.com/golang-jwt/jwt/v5 di framework Go Fiber.

package middleware

import (
	"errors"
	"strings"
	"time"

	"github.com/MicahParks/keyfunc/v2"
	"github.com/gofiber/fiber/v2"
	"github.com/golang-jwt/jwt/v5"
)

type AuthConfig struct {
	JWKSURL      string
	RateLimit    time.Duration
	FetchTimeout time.Duration
}

func NewJWTMiddleware(cfg AuthConfig) (fiber.Handler, error) {
	// Konfigurasi keyfunc dengan rate limiting dan on-demand refresh
	options := keyfunc.Options{
		RefreshRateLimit:  cfg.RateLimit,
		RefreshTimeout:    cfg.FetchTimeout,
		RefreshUnknownKID: true, // Otomatis fetch jika kid tidak ada di cache
	}

	jwks, err := keyfunc.Get(cfg.JWKSURL, options)
	if err != nil {
		return nil, err
	}

	return func(c *fiber.Ctx) error {
		authHeader := c.Get("Authorization")
		if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "missing or malformed authorization token",
			})
		}

		tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

		// Validasi token dan signature menggunakan cached keyfunc
		token, err := jwt.Parse(tokenStr, jwks.Keyfunc)
		if err != nil {
			// Tangani kegagalan IdP unreachable saat refresh on-demand
			if errors.Is(err, keyfunc.ErrKIDNotFound) {
				return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
					"error": "invalid key identifier (kid)",
				})
			}

			// Bedakan error sistem (503) dengan error client invalid/expired (401)
			if errors.Is(err, jwt.ErrTokenExpired) {
				return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
					"error": "token has expired",
				})
			}

			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "token verification failed: " + err.Error(),
			})
		}

		if !token.Valid {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "invalid token claims",
			})
		}

		// Simpan claims ke Locals untuk digunakan handler berikutnya
		c.Locals("user", token.Claims)
		return c.Next()
	}, nil
}

Inisialisasi pada Server Fiber

package main

import (
	"log"
	"time"

	"github.com/gofiber/fiber/v2"
	"yourproject/middleware"
)

func main() {
	app := fiber.New()

	jwtAuth, err := middleware.NewJWTMiddleware(middleware.AuthConfig{
		JWKSURL:      "https://idp.example.com/.well-known/jwks.json",
		RateLimit:    1 * time.Minute,
		FetchTimeout: 5 * time.Second,
	})
	if err != nil {
		log.Fatalf("Gagal inisialisasi JWKS client: %v", err)
	}

	api := app.Group("/api", jwtAuth)
	api.Get("/profile", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"status": "authenticated"})
	})

	log.Fatal(app.Listen(":3000"))
}

Strategi Error Handling: 401 Unauthorized vs 503 Service Unavailable

Bedakan respon error berdasarkan akar penyebab kegagalan parsing:

  • HTTP 401 Unauthorized: Token cacat, expired (jwt.ErrTokenExpired), algoritma tidak cocok (misal none atau HS256 saat IdP menetapkan RS256), atau kid tetap tidak ditemukan setelah proses refresh selesai dilakukan.
  • HTTP 503 Service Unavailable: Request ke IdP timeout saat mengeksekusi on-demand refresh, DNS lookup IdP gagal, atau IdP mengembalikan HTTP 5xx. Jangan kirim 401 saat IdP down; klien API butuh indikasi bahwa infrastruktur upstream sedang terganggu agar dapat mengaktifkan mekanisme retry/backoff.

Uji Kasus: Simulasi Rotasi Kunci (Key Rotation)

Lakukan verifikasi ketahanan middleware terhadap rotasi kunci dengan tahapan skenario berikut:

  1. Baseline Validasi: Terbitkan token A dengan kid: "key-2025-a". Kirim ke API. Token valid seketika via in-memory cache tanpa HTTP fetch tambahan.
  2. Rotasi di IdP: Tambahkan public key baru pada JWKS IdP dengan kid: "key-2025-b".
  3. Request Token Baru: Kirim request menggunakan token B (kid: "key-2025-b"). Keyfunc mendeteksi unknown key, memicu HTTP GET ke IdP, memperbarui storage cache, dan memvalidasi token sukses tanpa perlu restart aplikasi.
  4. Mitigasi DoS via Random KID: Kirim 50 request konkuren dengan token acak (kid: "fake-1", kid: "fake-2", ...). Request pertama memicu refresh IdP; 49 request berikutnya tertahan oleh RefreshRateLimit dan langsung ditolak 401 tanpa membanjiri IdP.

Trade-offs & Praktik Terbaik

  • Worker Thread Safety: Pustaka keyfunc mengelola sinkronisasi sync.RWMutex secara internal saat read/write cache. Hindari membuat map cache manual di luar pustaka teruji untuk mencegah race condition.
  • Cold Start Latency: Inisialisasi keyfunc.Get memblokir hingga fetch pertama berhasil. Pastikan dependensi jaringan IdP siap sebelum memanggil app.Listen pada Go Fiber.