Go Fiber: rotasi refresh token dan deteksi reuse yang aman diperlukan ketika Anda ingin sesi login tetap nyaman dipakai, tetapi tidak membuka celah besar saat refresh token bocor. Pola yang disarankan adalah: access token berumur pendek, refresh token sekali pakai, token disimpan sebagai hash di server, lalu setiap refresh menghasilkan token baru dan menonaktifkan token lama.
Masalah utama bukan hanya bagaimana menerbitkan refresh token, tetapi bagaimana mendeteksi reuse saat token lama dipakai kembali. Jika token yang sudah pernah diputar masih muncul lagi, itu tanda kuat bahwa token bocor, disalin, atau terjadi replay. Solusi yang aman adalah mencatat hubungan token dalam satu session family, lalu saat reuse terdeteksi, cabut keluarga sesi terkait tanpa memaksa logout semua user lain.
Kenapa refresh token perlu dirotasi
Jika refresh token bersifat jangka panjang dan bisa dipakai berulang, kebocoran satu token memberi penyerang akses persisten. Access token mungkin pendek umurnya, tetapi penyerang cukup memakai refresh token untuk terus meminta access token baru.
Dengan refresh token rotation:
- setiap refresh token hanya valid untuk satu kali pemakaian,
- saat dipakai, server menerbitkan refresh token baru,
- token lama ditandai tidak aktif atau sudah dipakai,
- jika token lama muncul lagi, server bisa mendeteksi indikasi penyalahgunaan.
Ini tidak membuat sistem kebal, tetapi secara signifikan memperkecil dampak kebocoran token.
Model alur autentikasi yang direkomendasikan
1. Login
- User berhasil autentikasi dengan email/password, OTP, atau metode lain.
- Server membuat access token berumur pendek, misalnya beberapa menit.
- Server membuat refresh token acak dengan entropi tinggi.
- Server menyimpan hash refresh token ke database/session store, bukan nilai mentahnya.
- Refresh token dikirim ke klien, idealnya lewat cookie aman untuk aplikasi web.
2. Akses API
Access token dipakai untuk request biasa melalui header Authorization: Bearer .... Saat access token kedaluwarsa, klien memanggil endpoint refresh.
3. Refresh
- Klien mengirim refresh token aktif.
- Server menghitung hash token dan mencari rekamannya.
- Jika valid dan belum pernah dipakai, server menandai token lama sebagai used/revoked.
- Server membuat access token baru dan refresh token baru.
- Server menyimpan hash token baru dalam keluarga sesi yang sama.
4. Reuse detection
Jika refresh token yang seharusnya sudah tidak valid dipakai lagi, anggap itu sinyal kompromi. Respons paling aman adalah revokasi seluruh session family milik login tersebut, bukan logout semua user di sistem.
Struktur data yang aman dan cukup praktis
Jangan simpan refresh token mentah di database. Jika database bocor dan token tersimpan polos, penyerang bisa langsung memakainya. Simpan hash token, lalu bandingkan hash saat request datang.
Contoh skema tabel refresh session
CREATE TABLE auth_refresh_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
family_id UUID NOT NULL,
token_hash VARCHAR(128) NOT NULL UNIQUE,
parent_id UUID NULL,
issued_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
replaced_by_id UUID NULL,
user_agent TEXT NULL,
ip_address TEXT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_auth_refresh_sessions_user_id ON auth_refresh_sessions(user_id);
CREATE INDEX idx_auth_refresh_sessions_family_id ON auth_refresh_sessions(family_id);
CREATE INDEX idx_auth_refresh_sessions_expires_at ON auth_refresh_sessions(expires_at);Makna kolom penting
family_id: mengelompokkan semua refresh token hasil rotasi dari satu sesi login.parent_id: token sebelumnya dalam rantai rotasi.used_at: kapan token dipakai untuk refresh.revoked_at: kapan token dicabut paksa.replaced_by_id: token pengganti yang diterbitkan setelah refresh berhasil.
Dengan struktur ini Anda bisa melakukan audit, deteksi reuse, dan revokasi terbatas pada satu keluarga sesi.
Cookie aman vs header untuk refresh token
Cookie lebih cocok untuk aplikasi web browser
Untuk aplikasi web, refresh token umumnya lebih aman disimpan di cookie dengan atribut:
HttpOnly: JavaScript tidak bisa membaca token.Secure: hanya dikirim lewat HTTPS.SameSite: membantu mengurangi risiko CSRF, pilih sesuai arsitektur.Pathdibatasi, misalnya hanya ke endpoint refresh/logout.
Keuntungan utama cookie adalah mengurangi risiko token dicuri lewat XSS karena token tidak tersedia di localStorage atau memori JavaScript.
Header lebih umum untuk mobile atau non-browser client
Untuk mobile app atau service-to-service client, mengirim refresh token lewat header lebih mudah dikontrol. Namun keamanan penyimpanan bergantung pada secure storage di sisi klien.
Catatan: jika refresh token dikirim lewat cookie, pertimbangkan proteksi CSRF untuk endpoint refresh dan logout, terutama jika
SameSite=Nonedipakai.
Expiry yang masuk akal
Pola umum yang aman:
- Access token: pendek, misalnya beberapa menit.
- Refresh token: lebih panjang, misalnya beberapa hari atau minggu sesuai kebutuhan risiko produk.
Jangan membuat refresh token terlalu panjang tanpa kontrol tambahan. Semakin lama expiry, semakin panjang jendela serangan jika token bocor. Untuk aplikasi sensitif, kombinasikan dengan device binding ringan, step-up auth, atau batas idle session.
Implementasi ringkas di Go Fiber
Contoh berikut menunjukkan pola inti, bukan sistem lengkap. JWT pembuatan access token disederhanakan agar fokus ke rotasi refresh token.
Utilitas token dan hash
package auth
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"time"
)
type RefreshSession struct {
ID string
UserID string
FamilyID string
TokenHash string
ParentID sql.NullString
ReplacedByID sql.NullString
IssuedAt time.Time
ExpiresAt time.Time
UsedAt sql.NullTime
RevokedAt sql.NullTime
}
func generateOpaqueToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func isUsable(s RefreshSession, now time.Time) bool {
if s.RevokedAt.Valid || s.UsedAt.Valid {
return false
}
return now.Before(s.ExpiresAt)
}
var ErrReuseDetected = errors.New("refresh token reuse detected")Login handler
package handlers
import (
"time"
"github.com/gofiber/fiber/v2"
)
type Store interface {
CreateRefreshSession(s RefreshSession) error
}
type Handler struct {
Store Store
}
func (h *Handler) Login(c *fiber.Ctx) error {
// Validasi kredensial dihilangkan untuk fokus contoh.
userID := "user-123"
familyID := newUUID()
refreshToken, err := generateOpaqueToken()
if err != nil {
return fiber.ErrInternalServerError
}
now := time.Now().UTC()
session := RefreshSession{
ID: newUUID(),
UserID: userID,
FamilyID: familyID,
TokenHash: hashToken(refreshToken),
IssuedAt: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
}
if err := h.Store.CreateRefreshSession(session); err != nil {
return fiber.ErrInternalServerError
}
accessToken := issueAccessToken(userID, 15*time.Minute)
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: refreshToken,
HTTPOnly: true,
Secure: true,
SameSite: "Lax",
Path: "/auth",
Expires: session.ExpiresAt,
})
return c.JSON(fiber.Map{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 900,
})
}Refresh handler dengan rotasi dan deteksi reuse
type RefreshStore interface {
GetByHashForUpdate(tokenHash string) (RefreshSession, error)
RotateRefreshToken(oldID string, newSession RefreshSession, usedAt time.Time) error
RevokeFamily(familyID string, revokedAt time.Time) error
}
func (h *Handler) Refresh(c *fiber.Ctx) error {
raw := c.Cookies("refresh_token")
if raw == "" {
return fiber.ErrUnauthorized
}
now := time.Now().UTC()
tokenHash := hashToken(raw)
session, err := h.Store.GetByHashForUpdate(tokenHash)
if err != nil {
return fiber.ErrUnauthorized
}
if session.RevokedAt.Valid || now.After(session.ExpiresAt) {
return fiber.ErrUnauthorized
}
if session.UsedAt.Valid {
_ = h.Store.RevokeFamily(session.FamilyID, now)
clearRefreshCookie(c)
return fiber.NewError(fiber.StatusUnauthorized, "refresh token reuse detected")
}
newRaw, err := generateOpaqueToken()
if err != nil {
return fiber.ErrInternalServerError
}
newSession := RefreshSession{
ID: newUUID(),
UserID: session.UserID,
FamilyID: session.FamilyID,
ParentID: toNullString(session.ID),
TokenHash: hashToken(newRaw),
IssuedAt: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
}
if err := h.Store.RotateRefreshToken(session.ID, newSession, now); err != nil {
return fiber.ErrConflict
}
accessToken := issueAccessToken(session.UserID, 15*time.Minute)
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: newRaw,
HTTPOnly: true,
Secure: true,
SameSite: "Lax",
Path: "/auth",
Expires: newSession.ExpiresAt,
})
return c.JSON(fiber.Map{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 900,
})
}
func clearRefreshCookie(c *fiber.Ctx) {
c.Cookie(&fiber.Cookie{
Name: "refresh_token",
Value: "",
HTTPOnly: true,
Secure: true,
SameSite: "Lax",
Path: "/auth",
Expires: time.Unix(0, 0),
})
}Kunci utama ada pada GetByHashForUpdate dan RotateRefreshToken yang harus berjalan atomik di database.
Mencegah race condition saat dua refresh bersamaan
Kasus umum: browser mengirim dua request refresh hampir bersamaan karena tab ganda, retry, atau network race. Jika tidak dikunci, keduanya bisa lolos dan masing-masing menerbitkan token baru. Akibatnya status rantai token rusak dan reuse detection bisa salah memicu.
Pendekatan yang lebih aman
- Gunakan transaksi database.
- Kunci baris token lama saat dibaca, misalnya dengan pola select-for-update pada database relasional.
- Dalam transaksi yang sama, cek bahwa token belum
useddan belumrevoked. - Tandai token lama
used_at = now, simpan token baru, isireplaced_by_id. - Commit transaksi.
Jika request kedua datang setelah request pertama selesai, token lama sudah bertanda used. Pada titik itu server harus memutuskan apakah ini benar-benar reuse berbahaya atau efek balapan request yang sah.
Trade-off penanganan race yang realistis
Pendekatan paling ketat adalah langsung menganggap pemakaian token lama setelah rotasi sebagai reuse dan mencabut keluarga sesi. Ini paling aman, tetapi bisa mengganggu pengguna pada kondisi jaringan buruk atau request ganda dari klien.
Pendekatan yang lebih toleran adalah memberi grace window sangat singkat untuk request duplikat yang datang nyaris bersamaan dan berasal dari konteks yang sama. Namun ini menambah kompleksitas dan sedikit melonggarkan model keamanan. Jika memilih grace window, dokumentasikan dengan jelas dan pastikan tidak mengubah token lama menjadi reusable.
Untuk banyak API internal atau aplikasi bisnis biasa, pendekatan ketat dengan penguncian transaksi sudah cukup baik asalkan klien tidak membanjiri endpoint refresh.
Contoh logika rotasi di layer penyimpanan
func (s *SQLStore) RotateRefreshToken(oldID string, newSession RefreshSession, usedAt time.Time) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
row := tx.QueryRow(`
SELECT id, user_id, family_id, token_hash, expires_at, used_at, revoked_at
FROM auth_refresh_sessions
WHERE id = ?
FOR UPDATE
`, oldID)
var current RefreshSession
if err := scanRefreshSession(row, ¤t); err != nil {
return err
}
if current.UsedAt.Valid || current.RevokedAt.Valid || time.Now().UTC().After(current.ExpiresAt) {
return ErrReuseDetected
}
if _, err := tx.Exec(`
UPDATE auth_refresh_sessions
SET used_at = ?, replaced_by_id = ?, updated_at = ?
WHERE id = ?
`, usedAt, newSession.ID, usedAt, oldID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO auth_refresh_sessions
(id, user_id, family_id, token_hash, parent_id, issued_at, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
newSession.ID,
newSession.UserID,
newSession.FamilyID,
newSession.TokenHash,
newSession.ParentID,
newSession.IssuedAt,
newSession.ExpiresAt,
newSession.IssuedAt,
newSession.IssuedAt,
); err != nil {
return err
}
return tx.Commit()
}Sintaks SQL detail akan berbeda tergantung database. Yang penting adalah prinsip atomiknya, bukan bentuk query spesifik.
Deteksi reuse yang benar
Reuse terjadi ketika refresh token yang seharusnya sudah tidak berlaku dipakai lagi. Dalam model sekali pakai, kondisi berikut perlu dianggap mencurigakan:
- token ditemukan tetapi
used_atsudah terisi, - token sudah
revoked, tetapi masih dikirim klien, - token lama dalam satu rantai rotasi muncul kembali setelah sudah diganti.
Respons yang disarankan:
- revokasi semua refresh token dalam
family_idyang sama, - hapus cookie refresh token di klien bila memungkinkan,
- catat audit log dengan user ID, family ID, IP, user-agent, waktu, dan token/session ID terkait,
- opsional: minta login ulang untuk keluarga sesi itu saja.
Dengan cara ini Anda membatasi dampak pada satu sesi login, bukan seluruh akun di semua perangkat.
Revokasi keluarga sesi tanpa logout massal
Sering terjadi kebocoran hanya pada satu perangkat atau satu browser session. Karena itu jangan langsung mencabut semua sesi user kecuali memang ada alasan risiko tinggi.
Kapan cabut satu family saja
- reuse terdeteksi pada satu refresh token chain,
- logout dari satu perangkat,
- anomali IP/user-agent pada satu sesi,
- user memilih “logout perangkat ini”.
Kapan cabut semua sesi user
- password baru saja diubah setelah indikasi kompromi,
- akun diambil alih,
- admin memaksa re-auth,
- kebijakan keamanan organisasi mengharuskan logout global.
Pemisahan ini penting agar sistem aman tetapi tidak merusak pengalaman pengguna normal.
Logout yang benar
Logout bukan hanya menghapus token di sisi klien. Server juga harus mencabut refresh token aktif yang terkait.
Logout satu perangkat
- ambil refresh token saat ini,
- hash token, cari sesi terkait,
- set
revoked_atuntuk token itu atau seluruh family perangkat tersebut, - hapus cookie refresh token.
Logout semua perangkat
Jika pengguna memilih logout semua perangkat, revokasi semua family milik user. Ini operasi berbeda dari revokasi reuse yang biasanya cukup satu family.
Audit log dan observabilitas
Endpoint refresh sering luput dari pengamatan, padahal ini jalur bernilai tinggi. Simpan audit log minimal untuk:
- login berhasil dan gagal,
- refresh berhasil,
- refresh gagal karena expired/revoked/not found,
- reuse detected,
- logout satu sesi dan logout semua sesi.
Field yang berguna:
- timestamp,
- user_id,
- session_id,
- family_id,
- IP address,
- user-agent,
- hasil aksi,
- reason code seperti
expired,used,revoked,reuse_detected.
Jangan log token mentah. Jika perlu korelasi, log hanya ID sesi atau hash yang dipotong secukupnya.
Rate limit endpoint refresh
Endpoint /auth/refresh layak diberi rate limit karena:
- sering jadi target brute force token,
- bisa dipakai untuk memicu race condition,
- melindungi database dari spam request.
Rate limit bisa diterapkan per kombinasi IP, user ID, atau fingerprint sesi. Untuk aplikasi publik di balik NAT, jangan hanya bergantung pada IP agar tidak memblokir banyak pengguna sah sekaligus.
Di Go Fiber, Anda bisa menambahkan middleware rate limiter khusus di route refresh. Detail implementasinya bergantung pada penyimpanan limiter yang dipakai, tetapi prinsipnya adalah membatasi burst pendek dan total request dalam interval tertentu.
Strategi cleanup token kedaluwarsa
Data refresh session akan terus bertambah. Anda perlu strategi pembersihan agar tabel tidak membengkak.
Pilihan yang umum
- Scheduled cleanup: job periodik menghapus token yang sudah expired lama dan tidak diperlukan untuk audit aktif.
- Soft retention: simpan data beberapa hari/minggu untuk forensik, lalu hapus.
- Partitioning: bila volume sangat besar, partisi tabel berdasarkan waktu bisa membantu operasi maintenance.
Jangan terlalu cepat menghapus semua token used/revoked jika Anda masih membutuhkan jejak untuk mendeteksi pola serangan atau investigasi insiden. Namun jangan simpan tanpa batas jika tidak ada kebutuhan audit yang jelas.
Kesalahan umum yang perlu dihindari
- Menyimpan refresh token mentah di database.
- Mengizinkan satu refresh token dipakai berkali-kali.
- Tidak punya relasi keluarga sesi, sehingga reuse sulit dideteksi.
- Menghapus token lama tanpa jejak, sehingga replay tidak bisa dibedakan dari token tidak dikenal.
- Tidak mengunci transaksi saat rotasi, menyebabkan dua refresh berhasil bersamaan.
- Menyimpan refresh token di
localStoragepada aplikasi web tanpa memahami risiko XSS. - Tidak memberi rate limit pada endpoint refresh.
- Tidak membersihkan cookie saat token direvokasi.
Debugging saat implementasi
Gejala: user sering logout sendiri
Periksa apakah klien mengirim dua request refresh hampir bersamaan. Ini sering terjadi pada interceptor HTTP yang dipasang di banyak tab atau beberapa request 401 memicu refresh serentak.
Gejala: reuse terdeteksi padahal user normal
Cek apakah rotasi benar-benar atomik. Jika tidak, dua request bisa sama-sama membaca token dalam keadaan belum used.
Gejala: refresh selalu unauthorized
Periksa path cookie, domain cookie, HTTPS, pengaturan proxy, dan apakah cookie benar-benar ikut terkirim ke endpoint refresh.
Gejala: logout berhasil di klien tetapi token tetap aktif
Pastikan logout juga mencabut sesi di server, bukan hanya menghapus cookie.
Trade-off keamanan vs kompleksitas
Model ini lebih aman daripada refresh token statis, tetapi menambah kompleksitas:
- perlu tabel/session store khusus,
- perlu transaksi atomik,
- perlu audit log dan cleanup,
- perlu penanganan kasus balapan request.
Sebagai gantinya, Anda memperoleh kontrol yang jauh lebih baik terhadap pencurian token, replay, dan revokasi per sesi. Untuk API auth yang menangani data sensitif atau akses jangka panjang, trade-off ini biasanya layak.
Checklist hardening produksi
- Gunakan access token pendek dan refresh token sekali pakai.
- Simpan refresh token sebagai hash, bukan plaintext.
- Kelompokkan token ke dalam
family_id. - Lakukan rotasi secara atomik dalam transaksi.
- Deteksi
used/revokedtoken sebagai sinyal reuse. - Revokasi satu keluarga sesi saat reuse terdeteksi.
- Kirim refresh token via cookie
HttpOnly+Secureuntuk web. - Batasi path cookie ke endpoint auth yang relevan.
- Terapkan proteksi CSRF sesuai model cookie Anda.
- Tambahkan rate limit pada endpoint refresh dan logout.
- Simpan audit log tanpa mencatat token mentah.
- Siapkan cleanup terjadwal untuk token kedaluwarsa.
- Uji skenario refresh ganda, retry, logout, dan revoke family.
- Pantau metrik refresh gagal, reuse detected, dan lonjakan request.
Penutup
Implementasi Go Fiber: rotasi refresh token dan deteksi reuse yang aman bukan sekadar menambah endpoint refresh. Intinya adalah memperlakukan refresh token sebagai kredensial bernilai tinggi: sekali pakai, disimpan sebagai hash, diputar secara atomik, dan diawasi dengan audit serta rate limit.
Jika Anda hanya mengingat satu hal, ingat ini: jangan pakai refresh token jangka panjang yang reusable tanpa kontrol server-side. Dengan session family, reuse detection, dan revokasi terbatas per sesi, Anda bisa menaikkan keamanan API auth tanpa harus memaksa logout massal ke semua user.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!