Hardening auth dan session di Next.js App Router berarti memindahkan logika sensitif ke server, menyimpan session dalam cookie yang aman, membatasi akses dari sisi server, dan menutup celah umum seperti CSRF, token leakage, dan cache yang salah konfigurasi. Jika Anda masih menyimpan token di localStorage atau mengecek akses hanya di client, arsitektur tersebut perlu diperketat.
Artikel ini membahas pola praktis yang relevan untuk aplikasi Next.js App Router: login aman, cookie HttpOnly/Secure/SameSite, rotasi session setelah login, proteksi CSRF untuk aksi sensitif, validasi input berbasis skema, pembatasan akses route di middleware dan server component, serta cara menjaga secret environment agar tidak ikut masuk ke client bundle.
Mengapa auth di App Router perlu pendekatan server-first
Pada App Router, Next.js mendorong pemisahan yang lebih jelas antara kode server dan client. Ini cocok untuk keamanan karena data session, validasi kredensial, dan operasi sensitif semestinya tidak bergantung pada state di browser.
- Session lebih aman di cookie HttpOnly, karena JavaScript di browser tidak bisa membacanya secara langsung.
- Proteksi akses lebih kuat di server, karena route tidak hanya disembunyikan di UI, tetapi benar-benar ditolak sebelum data sensitif dikirim.
- Secret environment tetap di server, selama tidak diekspor ke komponen client atau diberi prefix publik.
Pola dasarnya sederhana: pengguna login ke route handler atau server action, server memverifikasi kredensial, server membuat session baru, lalu session ID disimpan di cookie aman. Untuk setiap request berikutnya, server membaca cookie, memuat session, lalu menentukan apakah pengguna berhak mengakses route atau aksi tertentu.
Arsitektur minimum yang disarankan
Untuk banyak aplikasi, arsitektur berikut sudah cukup kuat:
- Database/session store menyimpan session berdasarkan ID acak yang sulit ditebak.
- Cookie browser hanya menyimpan session ID, bukan payload sensitif atau token akses mentah.
- Route handler atau server action menangani login, logout, rotasi session, dan aksi sensitif.
- Middleware melakukan pemeriksaan awal untuk route yang dilindungi.
- Server component tetap melakukan otorisasi final sebelum merender data sensitif.
Catatan: Middleware cocok untuk early reject dan redirect, tetapi jangan mengandalkannya sebagai satu-satunya lapisan keamanan. Otorisasi final sebaiknya tetap dilakukan di server component, route handler, atau server action yang benar-benar mengakses data.
Login aman dengan cookie HttpOnly, Secure, dan SameSite
Prinsip yang perlu dipegang
- Jangan simpan access token atau session token di localStorage. Jika terjadi XSS, token bisa dicuri dengan mudah.
- Gunakan cookie HttpOnly agar token/session ID tidak dapat diakses dari JavaScript client.
- Gunakan Secure agar cookie hanya dikirim lewat HTTPS.
- Gunakan SameSite untuk mengurangi risiko CSRF. Umumnya Lax cukup baik untuk banyak aplikasi web tradisional, tetapi aksi sensitif tetap sebaiknya memiliki proteksi CSRF eksplisit.
Contoh route handler login
Contoh berikut menunjukkan pola dasar: validasi input, verifikasi password, buat session ID baru, simpan ke database, lalu kirim cookie aman.
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { verifyPassword } from '@/lib/auth/password'
import { createSession, findUserByEmail } from '@/lib/auth/session-store'
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export async function POST(req: Request) {
const body = await req.json()
const parsed = LoginSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Input tidak valid' }, { status: 400 })
}
const { email, password } = parsed.data
const user = await findUserByEmail(email)
if (!user) {
return NextResponse.json({ error: 'Email atau password salah' }, { status: 401 })
}
const ok = await verifyPassword(password, user.passwordHash)
if (!ok) {
return NextResponse.json({ error: 'Email atau password salah' }, { status: 401 })
}
const session = await createSession({ userId: user.id })
cookies().set('session', session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
expires: session.expiresAt
})
return NextResponse.json({ ok: true })
}Mengapa pendekatan ini lebih aman?
- Browser akan mengirim cookie secara otomatis ke server, tanpa perlu mengekspos token ke JavaScript client.
- Jika nanti Anda perlu mencabut session, server cukup menghapus atau menandai session di store sebagai tidak valid.
- Session ID acak di server lebih mudah dirotasi daripada menyimpan data otentikasi kompleks di client.
Catatan implementasi password
Password sebaiknya tidak pernah dibandingkan secara plaintext. Simpan password dalam bentuk hash yang kuat, lalu gunakan fungsi verifikasi dari library yang sesuai. Detail algoritma bisa berbeda tergantung stack Anda, tetapi prinsipnya tetap sama: hash terstandar, salt, dan verifikasi di server.
Rotasi session setelah login dan saat event penting
Salah satu kesalahan yang sering terjadi adalah mempertahankan identifier session lama setelah login. Ini membuka risiko session fixation, yaitu ketika penyerang memaksa korban memakai session identifier yang sudah diketahui sebelumnya.
Kapan session perlu dirotasi?
- Segera setelah login berhasil.
- Setelah perubahan password.
- Setelah eskalasi hak akses atau aksi keamanan penting.
Contoh rotasi session
Jika pengguna sebelumnya memiliki cookie session anonim atau session lama, hapus session lama dan buat yang baru.
import { cookies } from 'next/headers'
import { deleteSession, createSession } from '@/lib/auth/session-store'
export async function rotateSession(userId: string) {
const current = cookies().get('session')?.value
if (current) {
await deleteSession(current)
}
const nextSession = await createSession({ userId })
cookies().set('session', nextSession.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
expires: nextSession.expiresAt
})
}Rotasi ini efektif karena session lama tidak lagi valid. Walaupun ID lama sempat bocor, server tidak akan menerimanya setelah dicabut.
Proteksi CSRF untuk action sensitif
Cookie yang dikirim otomatis oleh browser memang nyaman, tetapi itu juga alasan mengapa CSRF harus diperhatikan. SameSite membantu, namun untuk operasi sensitif seperti ubah email, ubah password, hapus akun, atau transfer dana, sebaiknya tambahkan token CSRF yang divalidasi di server.
Pola sederhana double-submit token
Pola praktis yang umum dipakai:
- Server membuat token CSRF acak.
- Token disimpan di cookie non-HttpOnly atau ditanam di form yang dirender server.
- Saat submit, client mengirim token yang sama lewat form field atau header.
- Server membandingkan token dari request dengan token yang diharapkan.
Berikut contoh server action yang memverifikasi token CSRF dan input.
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/require-user'
import { updatePassword } from '@/lib/user/service'
const ChangePasswordSchema = z.object({
csrfToken: z.string().min(20),
currentPassword: z.string().min(8),
newPassword: z.string().min(12)
})
export async function changePasswordAction(formData: FormData) {
const user = await requireUser()
const parsed = ChangePasswordSchema.safeParse({
csrfToken: formData.get('csrfToken'),
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword')
})
if (!parsed.success) {
throw new Error('Input tidak valid')
}
const csrfCookie = cookies().get('csrf_token')?.value
if (!csrfCookie || csrfCookie !== parsed.data.csrfToken) {
throw new Error('CSRF token tidak valid')
}
await updatePassword({
userId: user.id,
currentPassword: parsed.data.currentPassword,
newPassword: parsed.data.newPassword
})
}Untuk endpoint JSON, token CSRF bisa dikirim lewat header khusus, misalnya X-CSRF-Token, lalu divalidasi di route handler. Intinya bukan nama headernya, melainkan fakta bahwa penyerang tidak bisa menebak atau membaca token tersebut dari origin lain.
Praktik baik: gabungkan SameSite, validasi Origin/Referer bila sesuai, dan token CSRF untuk aksi sensitif. Jangan mengandalkan satu mekanisme saja.
Validasi input dengan skema, bukan asumsi
Banyak celah keamanan berawal dari input yang dianggap valid tanpa pemeriksaan ketat. App Router memudahkan pemisahan endpoint dan action, tetapi itu tidak menghapus kebutuhan validasi di server.
Apa yang perlu divalidasi?
- Bentuk data: string, email, panjang minimal, enum, angka.
- Batasan bisnis: password baru tidak sama dengan password lama, role tidak bisa diubah sembarangan, dan sebagainya.
- Normalisasi: misalnya trim email atau ubah ke lowercase jika memang diperlukan oleh sistem Anda.
Gunakan satu skema untuk input route handler atau server action. Ini mengurangi kode duplikat dan membuat error handling lebih konsisten.
import { z } from 'zod'
export const CreateSessionSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(8).max(200)
})Skema tidak otomatis membuat sistem aman, tetapi skema mengurangi area salah parsing, tipe data yang tidak diharapkan, dan cabang logika yang luput ditangani.
Pembatasan akses route di middleware dan server component
Middleware untuk redirect awal
Middleware berguna untuk mencegah pengguna anonim masuk ke area privat sebelum route diproses lebih jauh.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(req: NextRequest) {
const session = req.cookies.get('session')?.value
const isProtected = req.nextUrl.pathname.startsWith('/dashboard')
if (isProtected && !session) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('next', req.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*']
}Namun cookie ada bukan berarti session valid. Middleware hanya cocok sebagai pemeriksaan cepat. Session tetap harus diverifikasi terhadap store di server ketika data sensitif benar-benar diakses.
Server component untuk otorisasi final
Di halaman privat, lakukan pemeriksaan final di server component atau helper server-side.
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth/get-current-user'
export default async function DashboardPage() {
const user = await getCurrentUser()
if (!user) {
redirect('/login')
}
return <div>Halo, {user.name}</div>
}Alasan pendekatan ini penting: UI yang tersembunyi di client tidak sama dengan akses yang ditolak di server. Jika data sudah terlanjur diambil sebelum pengecekan, proteksi visual saja tidak ada gunanya.
Otorisasi berbasis role atau permission
Untuk route admin atau operasi sensitif, jangan hanya cek apakah pengguna sudah login. Cek juga role atau permission yang relevan.
import { notFound } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth/get-current-user'
export default async function AdminPage() {
const user = await getCurrentUser()
if (!user || user.role !== 'admin') {
notFound()
}
return <div>Admin area</div>
}Menampilkan notFound() pada area tertentu kadang lebih baik daripada redirect, karena tidak mengungkap terlalu banyak informasi tentang keberadaan resource.
Mengelola secret environment agar tidak bocor ke client bundle
Pada Next.js, variabel environment yang diberi prefix NEXT_PUBLIC_ dimaksudkan untuk tersedia di sisi client. Ini berguna untuk konfigurasi publik, tetapi berbahaya jika dipakai untuk secret.
Aturan praktis
- Jangan beri prefix NEXT_PUBLIC_ pada API secret, signing key, database URL, private token, atau kredensial internal.
- Jangan impor modul server-only ke client component jika modul tersebut membaca secret environment.
- Pisahkan utilitas server dan client agar secret tidak ikut terseret melalui dependency graph.
Contoh yang aman:
// lib/auth/config.ts
export const authConfig = {
sessionSecret: process.env.AUTH_SESSION_SECRET
}File seperti ini sebaiknya hanya dipakai dari route handler, server action, atau server component. Hindari mengimpor file yang membaca secret ke komponen dengan 'use client'.
Kesalahan yang sering terjadi bukan hanya prefix publik, tetapi juga kebocoran tidak langsung: sebuah helper umum dipakai di client dan server, lalu helper itu membaca secret. Akibatnya boundary menjadi kabur. Cara paling aman adalah memisahkan folder atau modul khusus server.
Cache dan respons sensitif: jangan sampai data auth tersimpan keliru
Data terkait auth, profil privat, atau hasil pemeriksaan session tidak boleh diperlakukan seperti konten publik. Salah konfigurasi cache bisa menyebabkan data pengguna bocor ke request lain atau tetap tampil setelah logout.
Hal yang perlu diperhatikan
- Jangan cache respons sensitif secara publik.
- Hindari asumsi bahwa semua fetch aman untuk di-cache jika hasilnya bergantung pada cookie/session.
- Set header yang sesuai pada route handler yang mengembalikan data privat.
Contoh route handler yang mengembalikan data sensitif dengan header anti-cache:
import { NextResponse } from 'next/server'
import { requireUser } from '@/lib/auth/require-user'
export async function GET() {
const user = await requireUser()
return NextResponse.json(
{ id: user.id, email: user.email },
{
headers: {
'Cache-Control': 'no-store'
}
}
)
}Prinsipnya: jika output bergantung pada identitas pengguna atau session, perlakukan sebagai data privat. Ini juga membantu debugging ketika aplikasi terasa menampilkan state login yang tertinggal.
Rate limit endpoint auth
Endpoint login, reset password, magic link, dan verifikasi OTP adalah target alami untuk brute force dan abuse. Walaupun rate limiting tidak menggantikan hash password, audit log, atau captcha, ini tetap lapisan pertahanan yang penting.
Pendekatan yang umum
- Batasi percobaan per IP dalam jangka waktu tertentu.
- Gabungkan dengan identitas lain bila perlu, misalnya email atau username.
- Tambahkan jeda atau blokir sementara setelah percobaan gagal berulang.
- Log kejadian agar penyalahgunaan bisa dianalisis.
Implementasi rate limit bisa memakai Redis, store internal, atau layanan edge sesuai kebutuhan infrastruktur Anda. Detailnya bervariasi, tetapi tujuannya sama: memperlambat percobaan otomatis tanpa mengganggu pengguna normal secara berlebihan.
Trade-off: rate limiting yang terlalu agresif bisa mengganggu pengguna sah di jaringan bersama, sedangkan yang terlalu longgar tidak cukup menahan brute force. Uji dengan pola trafik realistis.
Contoh helper inti yang sering dipakai
Helper membaca session aktif
import { cookies } from 'next/headers'
import { findSessionWithUser } from '@/lib/auth/session-store'
export async function getCurrentUser() {
const sessionId = cookies().get('session')?.value
if (!sessionId) return null
const session = await findSessionWithUser(sessionId)
if (!session) return null
if (session.expiresAt < new Date()) {
return null
}
return session.user
}Helper mewajibkan login
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth/get-current-user'
export async function requireUser() {
const user = await getCurrentUser()
if (!user) redirect('/login')
return user
}Helper seperti ini membantu menjaga konsistensi. Daripada menulis logika auth berulang di banyak file, Anda punya satu jalur pemeriksaan yang mudah diaudit dan diuji.
Checklist kesalahan umum
- Menyimpan token di localStorage atau sessionStorage
Risikonya tinggi terhadap XSS. Untuk session web berbasis browser, cookie HttpOnly biasanya lebih aman. - Salah expose secret lewat NEXT_PUBLIC_
Prefix ini membuat variabel tersedia ke client. Jangan pakai untuk signing key, private token, atau secret auth. - Mengandalkan proteksi route di client saja
Menyembunyikan tombol atau redirect di client bukan kontrol keamanan. Otorisasi final harus di server. - Tidak merotasi session setelah login
Ini membuka risiko session fixation. - Tidak menambahkan proteksi CSRF pada action sensitif
SameSite membantu, tetapi untuk ubah password, hapus akun, dan aksi sejenis, tambahkan validasi token CSRF. - Kurang validasi input di server
Client validation hanya untuk UX. Server tetap harus memvalidasi semua input. - Cache respons sensitif
Data berbasis session sebaiknya tidak di-cache secara publik. GunakanCache-Control: no-storebila relevan. - Tidak menerapkan rate limit pada endpoint auth
Login dan reset password tanpa pembatasan mudah menjadi sasaran brute force. - Mencampur utilitas server dan client
Ini meningkatkan risiko secret environment ikut masuk ke bundle client atau boundary keamanan menjadi tidak jelas.
Tips debugging saat auth terasa “aneh”
- Periksa atribut cookie: apakah
HttpOnly,Secure,SameSite,Path, danExpiressudah sesuai? - Cek apakah cookie benar-benar terkirim pada request yang seharusnya terautentikasi.
- Validasi store session: session mungkin ada di browser, tetapi sudah expired atau tidak ada lagi di database.
- Audit header cache jika UI menampilkan status login yang tertinggal.
- Pastikan route handler/server action berjalan di server dan tidak memindahkan logika sensitif ke client component.
- Tinjau middleware matcher jika route privat tidak ikut terlindungi.
Penutup
Hardening auth dan session di Next.js App Router bukan soal menambah banyak lapisan secara acak, melainkan menempatkan kontrol keamanan di titik yang benar: session di cookie HttpOnly, verifikasi di server, rotasi session setelah login, CSRF protection untuk aksi sensitif, validasi input berbasis skema, pembatasan akses di middleware dan server component, serta pemisahan secret agar tidak bocor ke client.
Jika Anda ingin mulai dari perubahan paling berdampak, urutannya biasanya seperti ini: pindahkan token dari localStorage ke cookie HttpOnly, verifikasi session di server, tambahkan rotasi session, lalu tutup celah CSRF, cache, dan rate limiting. Dengan pola ini, implementasi auth di App Router akan jauh lebih tahan terhadap kesalahan umum yang sering muncul di aplikasi produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!