Sesi JWT Hibrida & Pencabutan Per Perangkat
Penggabungan token JWT HS256 di cookie HttpOnly dengan tabel sesi di database dan nomor versi sesi (sessionVersion).
Membaca sekitar 2 menit
Penggabungan token JWT HS256 di cookie HttpOnly dengan tabel sesi di database dan nomor versi sesi (sessionVersion).
Setiap token menyimpan klaim sid yang merujuk ke baris perangkat di user_sessions dan klaim ver yang langsung menggugurkan seluruh token lama saat password, email, atau status MFA berubah.
JWT murni cepat tetapi tidak bisa dicabut; mengawinkannya dengan sid dan throttling lastSeenAt 5 menit memberi kendali penuh tanpa membebani database.
Masalahnya dulu
Ketika menggunakan JWT murni tanpa tabel database, token yang sudah terlanjur dikirim ke browser akan terus hidup sampai masa berlakunya habis. Jika seorang staf lupa logout di komputer umum atau akunnya dicurigai bocor, tidak ada tombol 'Keluarkan perangkat ini' yang benar-benar bisa mematikan token tersebut dari sisi server. Namun jika setiap klik halaman harus menulis waktu aktif terbaru ke database, performa database akan tercekik oleh ribuan operasi UPDATE.
Menggabungkan klaim sid, sessionVersion, dan throttling
Solusi yang saya terapkan di common/lib/auth.ts adalah pendekatan hibrida. Saat login, server mencatat baris baru di tabel user_sessions berisi IP dan User-Agent perangkat, lalu menanamkan UUID baris tersebut ke klaim sid serta nomor sessionVersion pengguna ke klaim ver di dalam JWT HS256. Pengguna dapat membuka halaman /dashboard/security untuk melihat daftar semua perangkat yang sedang aktif dan mencabut salah satu sesi kapan saja. Lebih penting lagi, saat terjadi aksi krusial seperti ganti email, ubah role, atau reset MFA, kolom sessionVersion pada tabel users dinaikkan 1 angka sehingga semua token lama langsung ditolak.
Token di tangan klien boleh berusia 12 jam, tetapi nyawanya tetap berada di genggaman baris database dan nomor versi sesi.
Implementasi di ngodingan ini
Berikut adalah cuplikan pembuatan sesi dan pemeriksaan validitas sesi di common/lib/auth.ts:
export async function createSession(userUuid: string, sessionVersion: number) {
const exp = Math.floor(Date.now() / 1000) + DURATION_SEC;
const expiresAt = new Date(exp * 1000);
const meta = await getRequestMeta();
await prisma.userSession.deleteMany({
where: { userUuid, expiresAt: { lt: new Date() } },
});
const session = await prisma.userSession.create({
data: {
userUuid,
ip: meta.ip === "unknown" ? null : meta.ip,
userAgent: meta.userAgent,
expiresAt,
},
select: { uuid: true },
});
const token = await new SignJWT({ ver: sessionVersion, sid: session.uuid })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userUuid)
.setIssuedAt()
.setExpirationTime(exp)
.sign(getKey());
(await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: expiresAt,
path: "/",
});
}Jebakan dan detail yang harus dijaga
- Throttling pembaruan lastSeenAt setiap 5 menit: Di dalam getSession(), server hanya menjalankan UPDATE last_seen_at jika selisih waktu dari catatan terakhir sudah melewati 5 menit (5 * 60 * 1000 ms), menghemat beban tulis database secara drastis.