Hardening refresh token di Go Fiber tidak cukup dengan memberi masa berlaku panjang pada token. Jika refresh token dicuri dan server tidak menerapkan rotasi, reuse detection, serta pencabutan token turunan, penyerang dapat mempertahankan sesi pengguna tanpa terlihat.

Pendekatan yang lebih aman adalah memperlakukan refresh token sebagai credential jangka panjang yang harus dilacak statusnya di server. Artikel ini membahas desain praktis untuk Go Fiber: rotasi refresh token setiap kali digunakan, deteksi saat token lama dipakai ulang, penyimpanan token dalam bentuk hash di database atau Redis, invalidasi per device, absolute expiry vs sliding expiry, binding ke session/device, dan langkah respons insiden ketika reuse terdeteksi.

Model ancaman dan tujuan desain

Refresh token biasanya berumur lebih panjang daripada access token. Karena itu, dampak kebocorannya juga lebih besar. Risiko umum yang perlu diantisipasi:

  • Pencurian token dari client, misalnya lewat XSS, malware, browser extension, atau penyimpanan yang tidak aman.
  • Intersepsi token jika transport tidak dipaksa lewat HTTPS.
  • Replay, yaitu token lama dipakai ulang setelah token tersebut sebenarnya sudah dirotasi.
  • Race condition saat dua permintaan refresh terjadi hampir bersamaan dari device yang sama atau pihak yang berbeda.

Tujuan desain hardening:

  • Setiap refresh token hanya valid untuk satu kali penggunaan.
  • Server dapat mendeteksi reuse token yang sudah tidak valid.
  • Jika reuse terdeteksi, semua turunan sesi terkait dapat dicabut.
  • Token yang tersimpan di server tidak berupa token mentah.
  • Sesi dapat dicabut per device tanpa memengaruhi semua login pengguna.

Arsitektur yang disarankan

Prinsip inti

Pola yang paling umum dan aman untuk refresh token adalah:

  1. Saat login, server membuat session keluarga token untuk satu device.
  2. Server mengembalikan access token berumur pendek dan refresh token acak berentropi tinggi.
  3. Refresh token mentah hanya dikirim ke client, sedangkan server menyimpan hash-nya.
  4. Saat endpoint refresh dipanggil, server memverifikasi hash token, memastikan token masih aktif, lalu merotasi token: token lama ditandai tidak aktif dan token baru diterbitkan.
  5. Jika token lama dipakai lagi setelah rotasi, server menandainya sebagai reuse incident dan mencabut sesi terkait atau seluruh rantai turunannya.

Komponen data minimal

Anda bisa menyimpan state di PostgreSQL/MySQL untuk durabilitas, lalu menambahkan Redis untuk cache, rate limit, atau blacklist cepat. Untuk kontrol yang kuat, sumber kebenaran tetap sebaiknya di database.

Contoh skema logis:

users
- id
- email
- password_hash

sessions
- id
- user_id
- family_id
- device_id
- user_agent
- ip_created
- status                -- active, revoked, compromised, expired
- absolute_expires_at
- idle_expires_at       -- opsional untuk sliding expiry
- created_at
- updated_at
- revoked_at
- revoke_reason

refresh_tokens
- id
- session_id
- token_hash
- token_jti             -- identifier acak, opsional tapi berguna untuk observability
- parent_token_id       -- relasi rotasi
- issued_at
- expires_at
- used_at
- revoked_at
- replaced_by_token_id
- status                -- active, used, revoked, reused, expired
- client_ip
- user_agent

family_id mewakili satu rantai refresh token untuk satu sesi/device. Saat reuse terdeteksi, Anda bisa mencabut semua token dalam family tersebut.

Kenapa refresh token harus dirotasi?

Tanpa rotasi, refresh token bersifat statis sampai kedaluwarsa. Jika token bocor, penyerang bisa terus meminta access token baru selama refresh token masih aktif. Dengan rotasi:

  • Setiap penggunaan refresh token menghasilkan token baru.
  • Token lama langsung tidak valid lagi.
  • Jika token lama muncul kembali, itu indikasi kuat adanya kebocoran atau replay.

Inilah alasan reuse detection menjadi efektif hanya jika ada pelacakan state di server. Jika refresh token sepenuhnya stateless dan tidak disimpan server-side, Anda sulit membedakan penggunaan sah dari replay token lama.

Hash refresh token: jangan simpan token mentah

Refresh token sebaiknya berupa string acak dengan entropi tinggi, misalnya 32 byte atau lebih dari generator kriptografis. Token mentah hanya disimpan di client. Di server, simpan hash-nya.

Kenapa perlu hash?

  • Jika database bocor, penyerang tidak langsung mendapatkan refresh token yang bisa dipakai.
  • Pendekatannya mirip penyimpanan password, meski biaya hashing tidak harus setinggi password jika token sudah acak dan panjang.

Untuk token acak berentropi tinggi, SHA-256 sering cukup sebagai sidik cepat karena tidak ada ruang tebakan yang realistis seperti password manusia. Jika ingin pemisahan konteks dan kontrol tambahan, gunakan HMAC-SHA-256 dengan secret server.

package auth

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
)

func NewRefreshToken() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.RawURLEncoding.EncodeToString(b), nil
}

func HashRefreshToken(token string, secret []byte) []byte {
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(token))
    return mac.Sum(nil)
}

Catatan: jangan log token mentah. Jika butuh korelasi log, gunakan token_jti atau hash terpotong yang tidak bisa dipakai untuk autentikasi.

Struktur endpoint: login, refresh, logout

1. Login

Endpoint login melakukan autentikasi user dan membuat sesi baru per device. Output umumnya:

  • Access token berumur pendek, misalnya beberapa menit.
  • Refresh token berumur lebih panjang, dikirim lewat cookie HttpOnly/Secure atau mekanisme aman lain.

Yang perlu disimpan server:

  • session dengan family_id, device_id, absolute_expires_at, dan status aktif
  • baris refresh token pertama dengan status aktif
POST /auth/login

Request:
{
  "email": "user@example.com",
  "password": "...",
  "device_name": "Chrome macOS"
}

Response:
{
  "access_token": "...",
  "expires_in": 900,
  "session_id": "sess_123"
}

Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Lax; Path=/auth/refresh

2. Refresh

Endpoint refresh adalah pusat dari hardening ini. Alur ideal:

  1. Ambil refresh token dari cookie HttpOnly atau body jika arsitektur Anda memang mengharuskan begitu.
  2. Hash token, cari record aktif yang cocok.
  3. Validasi sesi: tidak revoked, belum melewati absolute expiry, dan jika memakai sliding expiry, masih dalam idle window.
  4. Dalam transaksi atomik, tandai token lama sebagai used, buat token baru, simpan hash token baru, dan kaitkan parent/child.
  5. Kembalikan access token baru dan refresh token baru.

Jika token ditemukan tetapi statusnya sudah used atau revoked, itu dapat mengindikasikan reuse. Respons aman biasanya adalah mencabut seluruh family sesi tersebut.

3. Logout

Logout minimal harus mencabut sesi aktif pada device terkait. Jika pengguna memilih logout dari semua device, cabut semua family milik user.

POST /auth/logout
POST /auth/logout-all

Saat logout:

  • set status session menjadi revoked
  • revoked semua refresh token aktif pada session/family
  • hapus cookie refresh token di client

Alur request refresh token yang aman

Happy path

  1. Client mengirim refresh token T1.
  2. Server menemukan hash(T1) dan statusnya active.
  3. Server menandai T1 sebagai used.
  4. Server membuat T2, menyimpan hash(T2), dan mengaitkan parent_token_id = T1.
  5. Server mengirim access token baru + refresh token T2.

Reuse path

  1. Penyerang atau client lama mengirim T1 lagi setelah T1 sudah dipakai.
  2. Server menemukan T1 tetapi statusnya bukan active, melainkan used/replaced.
  3. Server menyimpulkan ada reuse atau replay.
  4. Server menandai session/family sebagai compromised.
  5. Server mencabut seluruh token turunan dari family itu.
  6. Server memaksa login ulang dan mencatat insiden.

Jika Anda hanya menolak T1 tanpa mencabut turunannya, penyerang yang sudah memperoleh T2 atau token berikutnya masih bisa bertahan. Karena itu, pencabutan family sering menjadi pilihan yang lebih aman.

Pseudocode refresh dengan rotasi dan reuse detection

func RefreshHandler(c *fiber.Ctx) error {
    raw := readRefreshToken(c)
    if raw == "" {
        return unauthorized()
    }

    hash := HashRefreshToken(raw, refreshHashSecret)

    tx := db.Begin()
    defer tx.Rollback()

    rt := tx.FindRefreshTokenForUpdate(hash)
    if rt == nil {
        // token tidak dikenal: bisa token palsu atau sudah dibersihkan
        audit("refresh_unknown_token")
        return unauthorized()
    }

    sess := tx.FindSessionForUpdate(rt.SessionID)
    if sess == nil || sess.Status != "active" {
        return unauthorized()
    }

    if now().After(sess.AbsoluteExpiresAt) {
        tx.RevokeSession(sess.ID, "absolute_expired")
        tx.Commit()
        return unauthorized()
    }

    if sess.IdleExpiresAt != nil && now().After(*sess.IdleExpiresAt) {
        tx.RevokeSession(sess.ID, "idle_expired")
        tx.Commit()
        return unauthorized()
    }

    if rt.Status != "active" || rt.UsedAt != nil || rt.RevokedAt != nil {
        // reuse terdeteksi
        tx.MarkSessionCompromised(sess.ID, "refresh_token_reuse_detected")
        tx.RevokeTokenFamily(sess.FamilyID, "reuse_detected")
        tx.Commit()
        notifySecurityEvent(sess.UserID, sess.ID)
        return unauthorizedForceReauth()
    }

    newRaw, err := NewRefreshToken()
    if err != nil {
        return internalError()
    }

    newHash := HashRefreshToken(newRaw, refreshHashSecret)

    tx.MarkTokenUsed(rt.ID)
    newTokenID := tx.InsertRefreshToken({
        SessionID:       sess.ID,
        ParentTokenID:   rt.ID,
        TokenHash:       newHash,
        Status:          "active",
        IssuedAt:        now(),
        ExpiresAt:       min(sess.AbsoluteExpiresAt, now().Add(refreshTTL)),
        ClientIP:        c.IP(),
        UserAgent:       c.Get("User-Agent"),
    })
    tx.LinkReplacement(rt.ID, newTokenID)

    // sliding expiry opsional
    tx.BumpIdleExpiry(sess.ID, now().Add(idleTTL))

    accessToken := issueAccessToken(sess.UserID, sess.ID)

    tx.Commit()

    setRefreshCookie(c, newRaw)
    return c.JSON(fiber.Map{
        "access_token": accessToken,
        "token_type":   "Bearer",
    })
}

Poin penting dari pseudocode di atas:

  • Lock row atau transaksi atomik dibutuhkan untuk mengurangi race condition.
  • Status token lama harus diubah sebelum token baru dianggap aktif sepenuhnya.
  • Reuse detection tidak cukup dengan memeriksa expiry saja; Anda harus melacak status used/revoked.

Contoh struktur handler Go Fiber

Berikut potongan struktur yang lebih konkret, tetap disederhanakan agar fokus pada alur:

type AuthService struct {
    Store Store
}

func (s *AuthService) Login(c *fiber.Ctx) error {
    // 1. validasi kredensial user
    // 2. buat session + refresh token pertama
    // 3. issue access token
    // 4. set cookie refresh token
    return c.JSON(fiber.Map{"access_token": "..."})
}

func (s *AuthService) Refresh(c *fiber.Ctx) error {
    // implementasi mengikuti pseudocode transaksi di atas
    return nil
}

func (s *AuthService) Logout(c *fiber.Ctx) error {
    // revoke current session/device
    expireRefreshCookie(c)
    return c.SendStatus(fiber.StatusNoContent)
}

func RegisterRoutes(app *fiber.App, auth *AuthService, protected fiber.Handler) {
    authGroup := app.Group("/auth")
    authGroup.Post("/login", auth.Login)
    authGroup.Post("/refresh", auth.Refresh)
    authGroup.Post("/logout", auth.Logout)

    api := app.Group("/api", protected)
    api.Get("/me", func(c *fiber.Ctx) error {
        return c.JSON(fiber.Map{"ok": true})
    })
}

Middleware yang relevan

1. Middleware access token

Gunakan middleware untuk memverifikasi access token pada endpoint API biasa. Access token sebaiknya singkat umur hidupnya dan memuat klaim minimal seperti:

  • user ID
  • session ID
  • issued at / expiry
  • scope atau role jika diperlukan

Memasukkan session_id ke access token membantu ketika Anda perlu memeriksa apakah sesi sudah dicabut. Namun ada trade-off: jika setiap request harus cek database, latensinya bertambah. Solusi umum:

  • endpoint sensitif melakukan pengecekan sesi aktif ke cache/database
  • endpoint biasa cukup percaya pada expiry access token yang singkat

2. Rate limiting untuk refresh

Endpoint refresh harus dibatasi. Jika tidak, penyerang bisa mencoba token acak atau memicu banyak rotasi yang memperbesar beban backend.

Rate limit dapat berbasis kombinasi:

  • IP address
  • session ID
  • user ID
  • device ID

Redis sangat cocok untuk implementasi counter dan window.

3. Middleware device/session context

Jika Anda mengirim device_id atau punya cookie/sid terpisah, middleware dapat menambah konteks ke handler untuk audit dan kontrol kebijakan. Jangan jadikan fingerprint browser sebagai satu-satunya faktor keamanan karena sifatnya tidak stabil dan mudah salah deteksi.

Binding ke session/device: apa yang realistis?

Mengikat refresh token ke device atau sesi membantu membatasi dampak kompromi. Namun implementasinya perlu realistis.

Pendekatan yang disarankan

  • Session per device: setiap login membuat session terpisah, misalnya laptop dan ponsel memiliki family berbeda.
  • Device ID acak: simpan identifier acak yang dibuat server, bukan fingerprint invasif yang rapuh.
  • User-Agent dan IP sebagai sinyal, bukan bukti absolut. Gunakan untuk audit atau pemicu step-up auth, bukan untuk memblokir secara agresif tanpa toleransi.

Kapan binding terlalu ketat jadi masalah

Jika refresh token dipaksa cocok dengan IP publik terakhir, pengguna mobile yang berpindah jaringan akan sering ter-logout. Jika terlalu bergantung pada fingerprint browser, perubahan minor browser atau mode privasi dapat memutus sesi sah. Jadi, binding paling aman biasanya berbasis server-managed session/device ID, dengan IP/User-Agent sebagai metadata tambahan.

Absolute expiry vs sliding expiry

Absolute expiry

Absolute expiry berarti sesi tidak bisa diperpanjang melewati batas maksimum tertentu, misalnya 30 hari sejak login.

Kelebihan:

  • Membatasi umur kompromi maksimal.
  • Lebih mudah dianalisis dan diaudit.

Kekurangan:

  • Pengguna pasti harus login ulang setelah batas tercapai.

Sliding expiry

Sliding expiry memperpanjang masa idle sesi setiap kali refresh berhasil, misalnya idle 7 hari, tetapi tetap dibatasi absolute 30 hari.

Kelebihan:

  • Pengalaman pengguna lebih baik untuk sesi aktif.

Kekurangan:

  • State dan logika lebih kompleks.
  • Tanpa absolute cap, sesi bisa hidup terlalu lama.

Praktik yang umum dan aman: gabungkan keduanya. Gunakan sliding expiry untuk inactivity window, dan absolute expiry sebagai batas keras.

Penyimpanan di database vs Redis

Database relasional

Pilih database relasional jika Anda butuh:

  • riwayat token dan relasi parent-child
  • transaksi yang kuat untuk rotasi atomik
  • audit trail dan investigasi insiden

Redis

Pilih Redis untuk:

  • rate limiting
  • cache lookup cepat token/session
  • TTL alami untuk data sementara

Redis saja bisa dipakai untuk state refresh token, tetapi Anda perlu hati-hati dengan persistensi, restart, dan kebutuhan audit. Untuk banyak sistem, pola yang seimbang adalah:

  • database sebagai source of truth
  • Redis sebagai akselerator

Respons insiden saat reuse terdeteksi

Saat reuse detection memicu insiden, respons server sebaiknya tegas dan konsisten:

  1. Tandai sesi/family sebagai compromised.
  2. Cabut semua refresh token turunan pada family itu.
  3. Jika kebijakan Anda lebih ketat, cabut juga semua sesi user.
  4. Hapus atau tolak refresh cookie aktif.
  5. Log insiden dengan metadata: user ID, session ID, family ID, IP, User-Agent, waktu, dan token ID terkait.
  6. Opsional: kirim notifikasi keamanan ke pengguna atau minta login ulang + verifikasi tambahan.

Jangan mengungkap terlalu banyak detail ke client. Respons API cukup generik, misalnya unauthorized dan instruksi login ulang. Detail insiden cukup masuk ke audit log internal.

Kesalahan umum yang sering terjadi

  • Menyimpan token mentah di database. Jika database bocor, token langsung bisa dipakai.
  • Tidak merotasi refresh token. Token statis memperpanjang dampak kebocoran.
  • Tidak mendeteksi reuse. Server hanya mengembalikan 401 tanpa menandai compromise.
  • Tidak mencabut token turunan. Family token tetap hidup walau replay terdeteksi.
  • Tidak membatasi percobaan refresh. Endpoint jadi target brute force atau abuse.
  • Menganggap IP sebagai identitas tetap. Ini sering memutus sesi sah.
  • Refresh endpoint tidak atomik. Race condition dapat membuat dua token aktif sekaligus.
  • Access token terlalu panjang umurnya. Ini mengurangi manfaat refresh hardening.
  • Menyimpan refresh token di localStorage tanpa mitigasi XSS yang kuat. Untuk web, cookie HttpOnly biasanya lebih aman.
  • Tidak memisahkan sesi per device. Logout satu device jadi sulit, dan blast radius kompromi membesar.

Debugging dan observability

Sistem refresh token yang aman perlu mudah diobservasi. Minimal, catat metrik dan log berikut:

  • jumlah login sukses/gagal
  • jumlah refresh sukses
  • jumlah refresh gagal karena expired
  • jumlah reuse detected
  • jumlah session revoked per reason
  • latensi endpoint refresh

Gunakan identifier non-sensitif seperti session_id, family_id, dan token_jti untuk korelasi log. Hindari menulis token mentah ke log aplikasi, log reverse proxy, atau APM breadcrumbs.

Jika Anda melihat reuse detection palsu, periksa kemungkinan berikut:

  • client melakukan retry otomatis ke endpoint refresh
  • dua tab/browser memicu refresh bersamaan
  • load balancer atau worker race tanpa lock/transaksi yang benar
  • cookie lama belum terganti karena bug pada path/domain/expiry cookie

Contoh alur data per device

Misalkan pengguna login dari laptop dan ponsel:

  • Laptop membuat session A dengan family A.
  • Ponsel membuat session B dengan family B.
  • Jika refresh token di laptop ter-reuse, Anda bisa mencabut family A saja.
  • Sesi ponsel tetap aktif karena berada di family berbeda.

Ini adalah alasan penting mengapa invalidasi per device lebih fleksibel daripada satu refresh token global per user.

Rekomendasi implementasi produksi

  1. Gunakan access token pendek dan refresh token acak panjang.
  2. Simpan refresh token dalam bentuk hash, bukan plaintext.
  3. Terapkan rotasi refresh token setiap kali refresh berhasil.
  4. Lacak status token: active, used, revoked, reused.
  5. Gunakan family/session per device.
  6. Terapkan reuse detection dan cabut seluruh family saat insiden terdeteksi.
  7. Gabungkan sliding expiry dengan absolute expiry.
  8. Pakai transaksi database atau mekanisme lock agar rotasi atomik.
  9. Pasang rate limiting pada login dan refresh.
  10. Kirim refresh token lewat cookie HttpOnly + Secure untuk aplikasi web, jika arsitekturnya memungkinkan.
  11. Audit log semua event penting tanpa menyimpan token mentah.
  12. Sediakan endpoint logout device saat ini dan logout semua device.
  13. Uji skenario concurrency, retry, token reuse, dan revoke cascade.

Trade-off keamanan vs kompleksitas

Semakin aman alur refresh token, semakin besar state yang harus Anda kelola. Rotasi, parent-child relation, family revoke, dan deteksi reuse menambah tabel, transaksi, dan observability. Namun biaya ini sepadan untuk aplikasi yang menyimpan data sensitif atau mendukung banyak device.

Jika kebutuhan Anda sederhana, minimal lakukan tiga hal: hash token, rotasi refresh token, dan cabut sesi per device. Untuk sistem dengan risiko lebih tinggi, tambahkan reuse detection dengan revoke cascade, absolute + sliding expiry, dan audit insiden.

Intinya, refresh token bukan sekadar token berumur panjang. Ia adalah sesi jangka panjang yang perlu dikelola dengan state, rotasi, dan respons insiden yang jelas. Dengan desain ini, implementasi Go Fiber Anda akan jauh lebih tahan terhadap replay, kebocoran token, dan kompromi sesi yang sulit dideteksi.