Rotasi secret adalah kebutuhan normal dalam sistem autentikasi: secret bisa bocor, perlu diperbarui berkala, atau dipisahkan antar lingkungan. Masalahnya, jika implementasi terlalu sederhana, rotasi secret akan membuat semua session lama langsung tidak valid dan memaksa logout massal. Di aplikasi Nuxt.js, ini bisa dihindari dengan desain session yang tepat: gunakan cookie berbasis server, pisahkan access token dan refresh/session token, tambahkan key versioning (kid), serta sediakan grace period terkontrol.

Artikel ini fokus pada pendekatan praktis untuk Nuxt.js: Rotasi Secret dan Revokasi Session Tanpa Logout Massal. Kita akan membahas arsitektur yang aman, contoh struktur kode server route dan middleware, deteksi token usang, rate limit endpoint refresh, audit logging, hingga strategi migrasi bertahap agar perubahan bisa dirilis tanpa memutus session aktif secara kasar.

Arsitektur yang disarankan

Untuk menghindari logout massal saat secret berubah, jangan bergantung pada satu token stateless jangka panjang yang hanya diverifikasi dengan satu secret aktif. Pendekatan yang lebih aman adalah memisahkan dua lapisan:

  • Access token berumur pendek, dipakai untuk akses request sehari-hari.
  • Refresh/session token berumur lebih panjang, dipakai hanya untuk meminta access token baru.

Jika secret untuk menandatangani access token dirotasi, session tidak harus langsung mati selama refresh/session token masih valid dan bisa digunakan untuk menerbitkan access token baru dengan secret terbaru.

Komponen inti

  • Cookie HTTP-only untuk menyimpan token, agar tidak mudah diakses JavaScript di browser.
  • Session store di server atau database untuk mencatat status session per device.
  • Key registry yang memuat beberapa secret aktif sekaligus, masing-masing punya kid.
  • Revocation state untuk mematikan session tertentu tanpa memengaruhi perangkat lain.
  • Audit log untuk mendeteksi pola refresh yang mencurigakan atau penyalahgunaan token.

Kenapa tidak cukup satu JWT jangka panjang?

JWT stateless jangka panjang memang sederhana, tetapi rotasi secret jadi sulit. Saat secret diganti, semua token yang ditandatangani secret lama akan gagal diverifikasi. Menyimpan beberapa secret lama memang membantu, tetapi tanpa session store Anda akan kesulitan melakukan revokasi per-device, mendeteksi replay, dan memaksa refresh terkontrol.

Prinsip penting: buat access token murah untuk diganti, dan buat refresh/session token menjadi titik kontrol utama yang bisa dilacak, dirotasi, dan direvokasi per-device.

Desain token: access vs refresh/session token

Access token

Karakteristik yang disarankan:

  • Masa berlaku pendek, misalnya hitungan menit, bukan hari.
  • Memuat klaim minimum: sub (user id), sid (session id), kid, exp, dan jika perlu role atau scope.
  • Diverifikasi di setiap request protected.

Karena umurnya pendek, access token yang ditandatangani secret lama akan cepat habis sendiri. Ini mengurangi kebutuhan untuk memutus semua session sekaligus saat rotasi secret.

Refresh/session token

Karakteristik yang disarankan:

  • Disimpan di cookie terpisah.
  • Umurnya lebih panjang daripada access token.
  • Terhubung ke record session di database.
  • Diregenerasi saat refresh berhasil untuk mengurangi replay.
  • Menyimpan sid, uid, dan kid atau metadata lain yang cukup untuk validasi.

Refresh token sebaiknya tidak dipakai sebagai token akses API biasa. Fungsinya hanya untuk memperoleh access token baru dan, bila perlu, sekaligus merotasi refresh token itu sendiri.

Key versioning dengan kid dan grace period

Kunci agar rotasi secret tidak menimbulkan logout massal adalah kemampuan menerima lebih dari satu secret secara sementara. Setiap token perlu menyimpan versi key atau pengenal key, biasanya disebut kid.

Contoh registry secret

Daripada satu environment variable tunggal, gunakan struktur registry yang menyatakan key aktif dan key lama yang masih boleh dipakai selama masa transisi.

// server/utils/auth-keys.ts
export type AuthKey = {
  kid: string
  secret: string
  status: 'active' | 'grace' | 'retired'
}

export function getAuthKeys(): AuthKey[] {
  // Nilai nyata sebaiknya diambil dari secret manager / env yang tervalidasi
  const raw = process.env.AUTH_KEYS_JSON || '[]'
  const keys = JSON.parse(raw)
  return keys.filter((k: AuthKey) => k && k.kid && k.secret)
}

export function getActiveSigningKey(): AuthKey {
  const active = getAuthKeys().find(k => k.status === 'active')
  if (!active) {
    throw new Error('No active signing key configured')
  }
  return active
}

export function findKeyByKid(kid: string): AuthKey | undefined {
  return getAuthKeys().find(k => k.kid === kid)
}

Dengan model ini:

  • active: dipakai untuk menandatangani token baru.
  • grace: masih diterima untuk verifikasi token lama selama masa transisi.
  • retired: tidak lagi diterima.

Kenapa kid penting?

Tanpa kid, server harus mencoba banyak secret satu per satu untuk memverifikasi token. Itu tidak efisien dan memperumit observabilitas. Dengan kid, server langsung tahu key mana yang dipakai untuk memverifikasi token, dan bisa memberi keputusan yang konsisten: masih valid, usang tapi boleh refresh, atau harus ditolak.

Grace period yang aman

Grace period adalah interval saat secret lama masih diterima untuk token yang sudah terbit sebelum rotasi. Tujuannya bukan mempertahankan token lama selamanya, melainkan memberi waktu agar client memperoleh token baru secara bertahap.

Prinsip aman untuk grace period:

  • Batasi durasinya.
  • Izinkan verifikasi token lama hanya jika session dasarnya masih valid.
  • Terbitkan ulang token menggunakan key aktif sesegera mungkin saat refresh.
  • Catat penggunaan token dari key lama agar mudah dimonitor.

Implementasi di Nuxt server route dan middleware

Pada Nuxt, logika ini umumnya ditempatkan di server/api, server/utils, dan server/middleware. Fokusnya adalah: baca cookie, verifikasi token memakai kid, cek session di store, lalu tentukan apakah request boleh lanjut, perlu refresh, atau harus ditolak.

Contoh struktur folder

server/
  api/
    auth/
      login.post.ts
      refresh.post.ts
      logout.post.ts
      logout-device.post.ts
  middleware/
    auth.ts
  utils/
    auth-keys.ts
    auth-tokens.ts
    session-store.ts
    audit-log.ts

Utility token yang aman

// server/utils/auth-tokens.ts
import { getActiveSigningKey, findKeyByKid } from './auth-keys'

export async function signAccessToken(payload: Record<string, any>) {
  const key = getActiveSigningKey()
  // Gunakan library signing token yang Anda pakai di proyek.
  // Contoh ini bersifat generik.
  return await signToken({ ...payload, kid: key.kid }, key.secret, {
    expiresIn: '15m'
  })
}

export async function signRefreshToken(payload: Record<string, any>) {
  const key = getActiveSigningKey()
  return await signToken({ ...payload, kid: key.kid }, key.secret, {
    expiresIn: '30d'
  })
}

export async function verifyTokenWithKid(token: string) {
  const decoded = await decodeToken(token)
  if (!decoded?.kid) {
    throw createError({ statusCode: 401, statusMessage: 'Missing kid' })
  }

  const key = findKeyByKid(decoded.kid)
  if (!key || key.status === 'retired') {
    throw createError({ statusCode: 401, statusMessage: 'Unknown or retired key' })
  }

  const payload = await verifyToken(token, key.secret)
  return {
    payload,
    keyStatus: key.status,
    kid: key.kid
  }
}

Fungsi decodeToken, verifyToken, dan signToken di atas sengaja generik. Anda bisa menggantinya dengan library yang dipakai di proyek, selama mendukung pembacaan klaim dan verifikasi signature dengan secret yang benar.

Middleware auth untuk request protected

// server/middleware/auth.ts
import { getCookie, setResponseStatus } from 'h3'
import { verifyTokenWithKid } from '../utils/auth-tokens'
import { getSessionById, isSessionRevoked } from '../utils/session-store'

export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/private')) return

  const accessToken = getCookie(event, 'access_token')
  if (!accessToken) {
    setResponseStatus(event, 401)
    return { error: 'Unauthenticated' }
  }

  try {
    const { payload, keyStatus } = await verifyTokenWithKid(accessToken)
    const session = await getSessionById(payload.sid)

    if (!session || await isSessionRevoked(session.id)) {
      setResponseStatus(event, 401)
      return { error: 'Session revoked' }
    }

    event.context.auth = {
      userId: payload.sub,
      sessionId: payload.sid,
      tokenKid: payload.kid,
      keyStatus
    }
  } catch {
    setResponseStatus(event, 401)
    return { error: 'Invalid token' }
  }
})

Middleware ini tidak langsung memaksa logout hanya karena token ditandatangani key lama. Jika keyStatus bernilai grace, request masih bisa diterima selama token dan session valid. Setelah itu, client didorong untuk refresh agar memperoleh token baru dari key aktif.

Endpoint refresh dengan rotasi token

// server/api/auth/refresh.post.ts
import { getCookie, setCookie, deleteCookie } from 'h3'
import { verifyTokenWithKid, signAccessToken, signRefreshToken } from '../../utils/auth-tokens'
import {
  getSessionById,
  rotateSessionToken,
  markSessionSeen,
  isSessionRevoked
} from '../../utils/session-store'
import { writeAuditLog } from '../../utils/audit-log'

export default defineEventHandler(async (event) => {
  const refreshToken = getCookie(event, 'refresh_token')
  if (!refreshToken) {
    throw createError({ statusCode: 401, statusMessage: 'Missing refresh token' })
  }

  try {
    const { payload, keyStatus } = await verifyTokenWithKid(refreshToken)
    const session = await getSessionById(payload.sid)

    if (!session || await isSessionRevoked(session.id)) {
      await writeAuditLog(event, 'refresh_denied_revoked', { sid: payload.sid })
      throw createError({ statusCode: 401, statusMessage: 'Session revoked' })
    }

    if (session.refreshTokenHash !== hashToken(refreshToken)) {
      await writeAuditLog(event, 'refresh_denied_mismatch', { sid: payload.sid })
      throw createError({ statusCode: 401, statusMessage: 'Refresh token mismatch' })
    }

    const newAccessToken = await signAccessToken({
      sub: payload.sub,
      sid: payload.sid
    })

    const newRefreshToken = await signRefreshToken({
      sub: payload.sub,
      sid: payload.sid
    })

    await rotateSessionToken(session.id, hashToken(newRefreshToken))
    await markSessionSeen(session.id)

    setCookie(event, 'access_token', newAccessToken, accessCookieOptions())
    setCookie(event, 'refresh_token', newRefreshToken, refreshCookieOptions())

    if (keyStatus === 'grace') {
      await writeAuditLog(event, 'refresh_from_grace_key', { sid: payload.sid, oldKid: payload.kid })
    }

    return { ok: true }
  } catch (error) {
    deleteCookie(event, 'access_token')
    deleteCookie(event, 'refresh_token')
    throw error
  }
})

Poin penting di sini:

  • Refresh token diverifikasi berdasarkan kid.
  • Session dicek ke database, bukan hanya mengandalkan signature token.
  • Hash refresh token disimpan di database, bukan token mentah.
  • Setelah refresh sukses, refresh token ikut dirotasi agar token lama tidak bisa dipakai ulang seenaknya.

Session store dan revokasi per-device

Agar logout tidak massal, sistem harus bisa mematikan session tertentu saja. Karena itu, setiap login perlu menghasilkan session record per device.

Data minimum yang sebaiknya disimpan

  • id / sid
  • user_id
  • refresh_token_hash
  • created_at, last_seen_at, expires_at
  • revoked_at
  • user_agent dan metadata device secukupnya
  • ip_last_seen bila sesuai kebijakan privasi Anda

Contoh operasi session store

// server/utils/session-store.ts
export async function getSessionById(sessionId: string) {
  return await db.sessions.findUnique({ where: { id: sessionId } })
}

export async function isSessionRevoked(sessionId: string) {
  const session = await getSessionById(sessionId)
  return !session || !!session.revokedAt || session.expiresAt < new Date()
}

export async function rotateSessionToken(sessionId: string, refreshTokenHash: string) {
  return await db.sessions.update({
    where: { id: sessionId },
    data: {
      refreshTokenHash,
      lastSeenAt: new Date()
    }
  })
}

export async function revokeSession(sessionId: string, reason = 'manual') {
  return await db.sessions.update({
    where: { id: sessionId },
    data: {
      revokedAt: new Date(),
      revokeReason: reason
    }
  })
}

Revokasi per-device

Dengan model ini, user bisa melihat daftar session aktif dan menghapus salah satu perangkat tanpa mengganggu perangkat lain. Implementasinya sederhana: endpoint seperti /api/auth/logout-device hanya menandai satu sid sebagai revoked.

Ini jauh lebih fleksibel dibanding menyimpan satu token global per user. Jika ada kebocoran di satu browser atau satu device, Anda tidak perlu memutus seluruh akun.

Cookie flags yang wajib divalidasi

Hardening auth berbasis cookie bukan hanya soal token. Kesalahan pada flag cookie sering lebih berbahaya daripada kesalahan pada kode verifikasi.

Rekomendasi cookie

function baseCookieOptions() {
  return {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/'
  }
}

function accessCookieOptions() {
  return {
    ...baseCookieOptions(),
    maxAge: 60 * 15
  }
}

function refreshCookieOptions() {
  return {
    ...baseCookieOptions(),
    maxAge: 60 * 60 * 24 * 30,
    path: '/api/auth'
  }
}

Penjelasan singkat:

  • httpOnly: mencegah akses dari JavaScript browser.
  • secure: hanya dikirim lewat HTTPS.
  • sameSite: membantu mitigasi CSRF; lax sering cukup untuk banyak aplikasi, tetapi kebutuhan Anda bisa berbeda.
  • path: batasi cakupan cookie, terutama refresh token agar tidak ikut terkirim ke semua endpoint.

Kesalahan umum

  • Menyimpan refresh token di localStorage.
  • Mengaktifkan cookie tanpa secure di environment produksi.
  • Memberi path terlalu luas untuk refresh token.
  • Tidak mempertimbangkan CSRF pada endpoint refresh/logout.

Jika Anda memakai cookie untuk autentikasi lintas situs atau domain khusus, evaluasi SameSite=None dengan hati-hati karena mensyaratkan secure dan meningkatkan risiko bila CSRF protection tidak matang.

Deteksi token usang dan fallback saat secret lama masih valid

Setelah rotasi secret, akan ada token yang masih valid secara signature tetapi ditandatangani dengan key berstatus grace. Jangan langsung menolaknya jika session masih sehat. Sebaliknya, perlakukan token tersebut sebagai token usang yang perlu diperbarui.

Strategi yang aman

  1. Request masuk dengan access token key lama.
  2. Server tetap menerima bila key masih dalam grace period dan session valid.
  3. Server menandai respons atau konteks bahwa token perlu refresh.
  4. Client memanggil endpoint refresh secara terkontrol.
  5. Server menerbitkan access dan refresh token baru dengan key aktif.

Pada implementasi tertentu, Anda bisa mengembalikan header atau status khusus yang memberi sinyal ke frontend untuk melakukan refresh diam-diam. Alternatif lain, frontend mencoba refresh hanya saat menerima 401 dari access token yang kedaluwarsa. Pilihan terbaik bergantung pada arsitektur aplikasi, tetapi intinya sama: fallback sementara boleh, penggunaan key lama tidak boleh permanen.

Jangan memperpanjang grace tanpa batas

Grace period yang terlalu lama akan mengurangi manfaat rotasi secret. Jika alasan rotasi adalah dugaan kebocoran, grace period harus lebih ketat dan bisa saja dilewati sama sekali untuk token tertentu yang berisiko tinggi.

Rate limit endpoint refresh dan pencegahan penyalahgunaan

Endpoint refresh adalah target penting. Jika tidak dibatasi, penyerang bisa mencoba replay, brute force session identifier, atau memicu beban tinggi dengan permintaan refresh berulang.

Yang perlu dibatasi

  • Jumlah refresh per IP dalam jangka waktu tertentu.
  • Jumlah refresh per sid.
  • Pola refresh gagal berturut-turut.
  • Refresh paralel berlebihan dari session yang sama.

Pendekatan implementasi

Nuxt bisa diintegrasikan dengan penyimpanan seperti Redis atau database cepat untuk mencatat counter rate limit. Detail teknisnya bergantung stack Anda, tetapi prinsipnya:

  • Gunakan key seperti refresh:ip:<ip> dan refresh:sid:<sid>.
  • Tambah counter dengan TTL singkat.
  • Tolak request jika melebihi ambang wajar.
  • Catat kejadian ke audit log.

Catatan: rate limit terlalu agresif bisa menyebabkan user sah gagal refresh saat jaringan buruk atau ada banyak tab browser terbuka. Uji skenario riil sebelum menetapkan ambang.

Audit logging untuk insiden dan debugging

Tanpa log audit, Anda akan sulit membedakan bug biasa dari penyalahgunaan token. Minimal, catat peristiwa yang terkait session dan rotasi secret.

Peristiwa yang layak dicatat

  • Login berhasil dan gagal.
  • Refresh berhasil.
  • Refresh gagal karena token mismatch.
  • Penggunaan token dengan key grace.
  • Revokasi session manual atau otomatis.
  • Lonjakan refresh dari IP atau device tertentu.

Contoh utilitas audit log

// server/utils/audit-log.ts
export async function writeAuditLog(event: any, action: string, metadata: Record<string, any> = {}) {
  await db.auditLogs.create({
    data: {
      action,
      path: event.path,
      ip: getRequestIP(event) || null,
      userAgent: getHeader(event, 'user-agent') || null,
      metadata: JSON.stringify(metadata),
      createdAt: new Date()
    }
  })
}

Log ini berguna saat Anda ingin menjawab pertanyaan seperti:

  • Apakah token lama masih dipakai setelah grace period seharusnya berakhir?
  • Apakah ada satu session yang melakukan refresh dari dua lokasi berbeda?
  • Apakah refresh gagal karena bug deploy, bukan karena serangan?

Migrasi bertahap tanpa memutus session aktif

Rotasi secret yang aman jarang dilakukan dalam satu langkah kasar. Lakukan migrasi bertahap agar sistem tetap stabil dan mudah dipantau.

Urutan migrasi yang disarankan

  1. Siapkan dukungan multi-key di verifier dan signer, termasuk kid.
  2. Deploy verifier dulu agar server mampu menerima key lama dan baru.
  3. Tambahkan key baru sebagai active, sementara key lama menjadi grace.
  4. Biarkan refresh alami terjadi; token baru akan ditandatangani key aktif.
  5. Monitor audit log untuk melihat penurunan penggunaan key lama.
  6. Retire key lama setelah grace period berakhir dan mayoritas session sudah bermigrasi.

Jika Anda langsung mengganti secret tanpa langkah 1 dan 2, hampir pasti semua token lama akan ditolak.

Skenario darurat

Jika ada indikasi secret lama benar-benar bocor, Anda mungkin perlu strategi yang lebih ketat:

  • Perpendek atau hilangkan grace period untuk token access.
  • Paksa refresh token hanya jika cocok dengan record session terbaru.
  • Revokasi session berisiko tinggi berdasarkan device, IP, atau waktu login tertentu.

Trade-off-nya jelas: keamanan naik, tetapi sebagian user bisa diminta login ulang.

Race condition yang sering muncul

Saat banyak tab browser aktif, endpoint refresh bisa dipanggil bersamaan. Ini memicu masalah klasik: request A dan B memakai refresh token yang sama, A berhasil merotasi token, lalu B datang belakangan dan tampak seperti replay.

Gejala

  • User tiba-tiba logout saat membuka banyak tab.
  • Refresh token mismatch muncul acak.
  • Audit log menunjukkan beberapa refresh berdekatan untuk sid yang sama.

Cara mitigasi

  • Single-flight di client: hanya satu proses refresh yang boleh berjalan pada satu waktu.
  • Lock singkat per session di server: gunakan mutex ringan di Redis atau mekanisme transaksi.
  • Rotation window kecil: izinkan token lama dipakai sekali lagi dalam jendela sangat pendek bila memang perlu, dengan pengawasan ketat.
  • Idempotency strategy: jika dua refresh datang hampir bersamaan, kembalikan hasil yang konsisten alih-alih menganggap semuanya serangan.

Tidak semua sistem perlu semua mitigasi ini. Namun, minimal Anda perlu memahami bahwa refresh token rotation tanpa pengendalian konkurensi sering menimbulkan bug yang terlihat seperti logout acak.

Penyimpanan secret via environment manager

Secret jangan ditulis keras di repository atau file konfigurasi yang mudah terbaca. Untuk Nuxt, praktik terbaiknya adalah mengambil secret dari environment manager atau secret manager yang dipakai infrastruktur Anda.

Prinsip penyimpanan secret

  • Bedakan secret per environment: development, staging, production.
  • Batasi akses hanya ke service yang membutuhkan.
  • Gunakan format yang tervalidasi saat startup, misalnya JSON untuk daftar key.
  • Hindari logging isi secret.
  • Siapkan prosedur rotasi yang terdokumentasi, bukan perubahan manual darurat.

Validasi saat boot

Salah satu kesalahan paling mahal adalah deploy dengan registry key yang tidak lengkap: signer memakai key baru, tetapi verifier belum bisa mengenalinya. Tambahkan validasi startup agar aplikasi gagal naik lebih awal bila konfigurasi key tidak valid.

Checklist pengujian sebelum dan sesudah rotasi

Sebelum rotasi

  • Pastikan verifier dapat membaca token dengan kid.
  • Pastikan signer menandatangani token dengan key aktif.
  • Uji refresh normal, logout, dan revokasi per-device.
  • Periksa cookie flags di browser dan respons HTTP.
  • Uji rate limit endpoint refresh.

Saat rotasi

  • Login baru harus mendapat token dengan kid baru.
  • User lama tetap bisa mengakses resource selama access token belum kedaluwarsa.
  • User lama bisa refresh dan menerima token baru tanpa login ulang.
  • Audit log mencatat penggunaan key grace.

Setelah grace period

  • Token dengan key lama ditolak.
  • Session yang sudah refresh tetap aktif normal.
  • Session yang tidak aktif terlalu lama mungkin memang perlu login ulang sesuai kebijakan.
  • Tidak ada endpoint yang masih menandatangani dengan key lama.

Debugging yang sering dibutuhkan

  • 401 mendadak setelah rotasi: cek apakah verifier mengenal kid baru.
  • Refresh loop: cek frontend memanggil refresh berulang karena access cookie tidak tersetel benar.
  • Logout acak di multi-tab: cek race condition pada rotasi refresh token.
  • Cookie tidak terkirim: cek secure, sameSite, domain, dan path.

Contoh alur aman end-to-end

  1. User login dari browser A.
  2. Server membuat session record sid-123 dan menyimpan hash refresh token.
  3. Server mengirim access_token dan refresh_token via cookie HTTP-only.
  4. Beberapa hari kemudian, secret dirotasi: kid=old menjadi grace, kid=new menjadi active.
  5. Request berikutnya dari user masih bisa lolos jika access token lama valid dan session belum direvokasi.
  6. Saat access token habis, browser memanggil /api/auth/refresh.
  7. Server menerima refresh token lama karena key masih grace, memverifikasi session, lalu menerbitkan access dan refresh token baru dengan kid=new.
  8. Jika user mencabut session browser A dari halaman perangkat aktif, hanya sid-123 yang direvokasi. Device lain tetap login.

Penutup

Poin utama dari Nuxt.js: Rotasi Secret dan Revokasi Session Tanpa Logout Massal adalah memindahkan kontrol dari token stateless jangka panjang ke model session yang bisa dilacak. Dengan access token berumur pendek, refresh/session token yang dirotasi, kid untuk multi-key verification, grace period terbatas, dan revokasi per-device, Anda bisa meningkatkan keamanan tanpa merusak pengalaman pengguna.

Jika Anda hanya mengambil satu pelajaran dari artikel ini, ambillah ini: jangan mendesain rotasi secret sebagai pergantian satu nilai environment semata. Anggap itu sebagai proses arsitektural yang mencakup penyimpanan key, validasi token, session store, observabilitas, dan migrasi bertahap.