Menyimpan JWT standar (JWS) dalam cookie tanpa enkripsi meninggalkan celah privasi: payload token dapat dibaca langsung oleh siapa saja yang memeriksa penyimpanan browser atau lalu lintas jaringan yang didekripsi. Saat token dicuri melalui serangan XSS atau eksfiltrasi cookie, penyerang memperoleh akses penuh hingga masa berlaku token habis.
Solusi standar industri untuk masalah ini adalah kombinasi JSON Web Encryption (JWE) dan Rotasi Refresh Token otomatis dengan deteksi token reuse. Panduan ini membahas implementasi teknis pola tersebut pada Next.js App Router menggunakan runtime Web Crypto API bawaan via pustaka jose.
Arsitektur Keamanan: JWE vs Plain JWT (JWS)
JWS (JSON Web Signature) hanya menjamin integritas data (mencegah manipulasi), tetapi tidak menjaga kerahasiaan (data di-encode dengan Base64URL biasa). Sebaliknya, JWE mengenkripsi payload sehingga isi klaim pengguna seperti userId, role, dan metadata sesi tidak dapat dibaca oleh pihak ketiga.
Trade-off Enkripsi Simetris (A256GCM)
Menggunakan enkripsi simetris langsung (algoritma dir dengan enkripsi konten A256GCM) memberikan keuntungan performa:
- Latensi:
A256GCMdidukung akselerasi hardware (AES-NI) pada level CPU. Overhead enkripsi dan dekripsi per request berkisar antara 0.05ms hingga 0.2ms, menjadikannya aman untuk middleware atau edge runtime. - Ukuran Payload: JWE menghasilkan string yang lebih panjang dibanding JWS karena menyertakan Initialization Vector (IV) dan Authentication Tag. Pastikan ukuran payload sesi tetap di bawah 2 KB untuk menghindari batas 4 KB per cookie pada peramban.
1. Utilitas Enkripsi dan Dekripsi Session
Gunakan pustaka jose karena kompatibel penuh dengan Web Cryptography API yang berjalan di Node.js, Vercel Edge Runtime, dan browser tanpa dependensi Node crypto lawas.
// lib/session.ts
import { EncryptJWT, jwtDecrypt } from 'jose';
const rawSecret = process.env.SESSION_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('SESSION_SECRET harus berupa string minimal 32 karakter.');
}
const SECRET_KEY = new TextEncoder().encode(rawSecret.slice(0, 32));
export interface SessionData {
userId: string;
sessionId: string;
role: string;
}
export async function encryptJWE(payload: SessionData, expiresIn: string = '15m'): Promise<string> {
return new EncryptJWT({ ...payload })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.encrypt(SECRET_KEY);
}
export async function decryptJWE(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtDecrypt(token, SECRET_KEY, {
algorithms: ['dir'],
contentEncryptionAlgorithms: ['A256GCM'],
});
return payload as unknown as SessionData;
} catch {
// Token kedaluwarsa atau manipulasi signature/IV
return null;
}
}
2. Pola Rotasi Refresh Token & Deteksi Token Reuse
Rotasi token berarti setiap kali refresh token digunakan untuk meminta access token baru, refresh token lama langsung dimusnahkan dan digantikan oleh refresh token baru. Mekanisme ini rentan desinkronisasi jika tidak dipadukan dengan Token Family Tracking.
Cara Kerja Deteksi Token Reuse
- Setiap sesi autentikasi dikelompokkan ke dalam satu
familyId. - Setiap refresh token memiliki nomor urut generasi (
generation) atau hash unik di basis data. - Jika server menerima request refresh dengan token generasi $N-1$ sementara generasi $N$ telah terbit, sistem mendeteksi pencurian token (replay attack).
- Aksi Otomatis: Seluruh sesi di dalam
familyIdtersebut langsung dianulir (dihapus dari DB) sehingga penyerang maupun korban ter-logout otomatis secara serentak.
// lib/auth-store.ts
// Contoh skema interaksi DB/Redis untuk token family
interface RefreshSession {
id: string;
familyId: string;
userId: string;
currentHash: string;
isRevoked: boolean;
}
// Simulasi query DB produksi
export async function rotateRefreshToken(
providedFamilyId: string,
incomingTokenHash: string,
newTokenHash: string
): Promise<{ success: boolean; userId?: string }> {
// Transaksi atomik diperlukan di database produksi (PostgreSQL/Redis)
const session = await getSessionByFamily(providedFamilyId);
if (!session || session.isRevoked) {
return { success: false };
}
// Deteksi Reuse: Token lama dikirim ulang padahal hash sudah diperbarui
if (session.currentHash !== incomingTokenHash) {
await revokeFamilySessions(providedFamilyId);
return { success: false };
}
// Token valid: lakukan rotasi
await updateSessionHash(session.id, newTokenHash);
return { success: true, userId: session.userId };
}
3. Implementasi Route Handler: Rotasi dan Sinkronisasi Cookie
Next.js App Router mengisolasi mutasi cookie pada Route Handler dan Server Actions. Berikut implementasi endpoint rotasi token /api/auth/refresh.
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
import { encryptJWE, decryptJWE } from '@/lib/session';
import { rotateRefreshToken } from '@/lib/auth-store';
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get('refresh_token')?.value;
const sessionToken = req.cookies.get('session_token')?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Ekstrak metadata sesi lama tanpa memvalidasi masa berlaku access token
// (access token mungkin sudah expired, ini perilaku normal saat refresh)
const oldSession = sessionToken ? await decryptJWE(sessionToken) : null;
const familyId = req.cookies.get('session_family')?.value;
if (!familyId) {
return NextResponse.json({ error: 'Invalid session family' }, { status: 401 });
}
const incomingHash = hashToken(refreshToken);
const nextRawRefreshToken = crypto.randomBytes(32).toString('hex');
const nextHash = hashToken(nextRawRefreshToken);
const rotation = await rotateRefreshToken(familyId, incomingHash, nextHash);
if (!rotation.success) {
// Terjadi token reuse atau token invalid: hapus seluruh cookie
const response = NextResponse.json({ error: 'Session compromised' }, { status: 401 });
response.cookies.delete('session_token');
response.cookies.delete('refresh_token');
response.cookies.delete('session_family');
return response;
}
// Buat JWE baru untuk access token (15 menit)
const newJWE = await encryptJWE(
{
userId: rotation.userId!,
sessionId: familyId,
role: oldSession?.role || 'user',
},
'15m'
);
const response = NextResponse.json({ ok: true });
// Cookie JWE Access Token
response.cookies.set('session_token', newJWE, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 15 * 60,
});
// Cookie Refresh Token Baru (7 Hari)
response.cookies.set('refresh_token', nextRawRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth', // Perkecil cakupan path cookie refresh
maxAge: 7 * 24 * 60 * 60,
});
return response;
}
4. Validasi Graceful pada Next.js Middleware
Middleware bertindak sebagai gerbang inspeksi. Middleware bertugas memverifikasi JWE sesi. Jika kedaluwarsa, middleware mengizinkan browser meneruskan request ke komponen klien atau API client-side yang akan otomatis memicu rotasi token tanpa crash.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { decryptJWE } from '@/lib/session';
const PUBLIC_ROUTES = ['/login', '/register', '/api/auth'];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Lewatkan rute publik
if (PUBLIC_ROUTES.some((route) => pathname.startsWith(route))) {
return NextResponse.next();
}
const sessionCookie = req.cookies.get('session_token')?.value;
if (!sessionCookie) {
return handleUnauthenticated(req);
}
const session = await decryptJWE(sessionCookie);
if (!session) {
// JWE invalid atau expired.
// Jangan langsung redirect jika request adalah panggilan data internal,
// biarkan client interceptor mengeksekusi /api/auth/refresh.
if (req.headers.get('accept')?.includes('application/json')) {
return NextResponse.json({ error: 'Token expired' }, { status: 401 });
}
return handleUnauthenticated(req);
}
// Teruskan data user ke downstream via custom header
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-user-id', session.userId);
requestHeaders.set('x-user-role', session.role);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
function handleUnauthenticated(req: NextRequest) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('redirect', req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Debugging dan Kesalahan Umum
- Path Cookie Tidak Sinkron: Menyimpan
refresh_tokendenganpath: '/api/auth'membatasi transmisi token hanya ke endpoint autentikasi, menghemat bandwidth request aset statis. Namun, jika Route Handler dipindahkan tanpa penyesuaianpath, cookie tidak akan dikirim oleh browser. - Batas Kunci Rahasia JOSE: Algoritma
A256GCMmewajibkan kunci dengan panjang tepat 256 bit (32 byte). Menggunakan string acak pendek tanpa hashing TextEncoder akan melempar error runtimeJWEInvalidKey. - Race Condition Refresh Paralel: Jika dua request bersamaan memicu refresh saat access token habis, request kedua akan ditolak karena hash telah berubah. Terapkan grace period 10–15 detik pada DB di mana token generasi sebelumnya masih ditoleransi sebelum dianggap sebagai serangan token reuse.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!