Kenapa ini penting
Hampir semua aplikasi nyata punya bagian yang tidak boleh dibuka sembarang orang: dashboard admin, halaman profil, riwayat pesanan. Untuk itu aplikasi harus menjawab dua pertanyaan berbeda:
- Autentikasi (authentication): "Kamu siapa?" Dibuktikan dengan password, login Google, atau kode OTP.
- Otorisasi (authorization): "Kamu boleh melakukan apa?" Misalnya hanya admin yang boleh menghapus artikel.
Lalu ada pertanyaan ketiga: bagaimana server mengingat bahwa kamu sudah login, padahal HTTP itu "pelupa" (setiap request berdiri sendiri)? Jawabannya adalah session. Kesalahan kecil di salah satu bagian ini bisa berakibat fatal, misalnya data semua pengguna bisa dibaca orang lain. Karena itu modul ini membahas konsepnya dengan teliti, membangun versi sederhana yang dianjurkan dokumentasi Next.js untuk belajar, lalu menunjukkan cara memakai library auth untuk produksi.
Analogi: login itu seperti check-in di hotel. Resepsionis memeriksa KTP-mu sekali (autentikasi), lalu memberimu kartu kamar (session). Setiap kali masuk lift atau kamar, kamu cukup menempelkan kartu, tidak perlu menunjukkan KTP lagi. Kartu itu juga menentukan lantai mana yang boleh kamu akses (otorisasi). Kalau kartunya palsu atau kedaluwarsa, pintu tidak terbuka.
Konsep inti
Session vs JWT: dua cara menyimpan "kartu kamar"
Dokumentasi Next.js membagi session menjadi dua jenis:
| Stateless (token di cookie, biasanya JWT) | Database session | |
|---|---|---|
| Isi cookie | Data session yang ditandatangani, misalnya { userId, role, expiresAt } |
Hanya ID session acak |
| Cek di server | Verifikasi tanda tangan, tanpa query DB | Cari ID di tabel sessions |
| Kelebihan | Sederhana, cepat, tanpa tabel tambahan | Bisa dicabut kapan saja ("logout dari semua perangkat"), lebih aman |
| Kekurangan | Sulit dicabut sebelum kedaluwarsa | Butuh query DB setiap request |
JWT (JSON Web Token) adalah format token berisi data JSON plus tanda tangan digital. Server menandatangani token dengan secret key; kalau ada yang mengubah isinya walau satu huruf, tanda tangannya tidak cocok dan token ditolak. Penting: JWT standar hanya ditandatangani, bukan dienkripsi. Isinya bisa dibaca siapa pun yang memegang token, jadi jangan simpan data sensitif (email, nomor HP, password) di dalamnya. Cukup ID dan peran.
Cookie yang aman
Token disimpan di cookie dengan opsi berikut (sesuai rekomendasi dokumentasi Next.js):
httpOnly: true→ tidak bisa dibaca JavaScript di browser, sehingga aman dari pencurian lewat XSS.secure: true→ hanya dikirim lewat HTTPS.sameSite: 'lax'→ tidak dikirim pada sebagian besar request dari situs lain (mengurangi risiko CSRF).expiresdanpath: '/'.
Di Next.js 16, cookies() adalah async: const cookieStore = await cookies(). Membaca cookie bisa di Server Component, tapi menulis atau menghapus cookie hanya bisa di Server Action atau Route Handler, karena header Set-Cookie harus dikirim sebelum HTML mulai di-stream.
Data Access Layer (DAL) dan verifySession
Dokumentasi Next.js menganjurkan membuat Data Access Layer: satu modul khusus server yang memusatkan pengecekan session dan akses data. Di dalamnya ada function seperti verifySession() yang membaca cookie, memverifikasi token, dan me-redirect('/login') bila tidak valid. Function ini dibungkus cache() dari React supaya dalam satu proses render hanya dijalankan sekali walau dipanggil di banyak komponen. (Catatan dari dokumentasi: Server Action dan Route Handler bukan bagian render pass, jadi di sana cache() tidak berefek dan function berjalan setiap dipanggil. Itu tidak masalah.)
Lapisan pertahanan yang lengkap:
- Proxy → redirect cepat (optimistis), Modul 5.
- DAL →
verifySession()dipanggil sebelum mengambil data sensitif. - Setiap Server Action → panggil
verifySession()di baris pertama. - Setiap Route Handler yang sensitif → cek session atau secret.
Jangan hanya cek di layout.js. Layout tidak dirender ulang di setiap navigasi, dan Server Action tetap bisa dipanggil langsung tanpa melewati layout.
Library auth: mana yang dipakai?
Dokumentasi Next.js menulis contoh username/password manual untuk tujuan edukasi, lalu menyarankan memakai library auth untuk produksi karena sudah menangani login sosial, MFA, rate limiting, reset password, dan banyak detail keamanan lain. Daftar library di dokumentasinya antara lain Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js (Auth.js), Ory, Stack Auth, Supabase, Stytch, dan WorkOS. Dokumentasi tidak memilih satu pemenang. Untuk session manual, ia menyebut jose dan iron-session.
Keputusan untuk materi ini:
- Untuk memahami konsep dan proyek akhir: session manual dengan
jose6.2.13, persis pola di dokumentasi Next.js. Tidak ada "sihir", kamu melihat setiap langkahnya. - Untuk aplikasi produksi dengan banyak pengguna: Better Auth 1.7.12. Open source, data tersimpan di database milikmu sendiri, punya adapter Drizzle dan Prisma, plus plugin
nextCookiesuntuk Server Action. - Alternatif: Auth.js/NextAuth (rilis stabil
next-authmasih 4.24.15, sedangkan v5 yang ditujukan untuk App Router masih berstatus beta5.0.0-beta.32saat materi ditulis), atau layanan terkelola seperti Clerk dan Auth0 bila kamu ingin UI login siap pakai dan tidak keberatan bergantung pada layanan pihak ketiga.
Contoh kode
Pertama, buat secret untuk menandatangani token dan simpan di .env.local:
openssl rand -base64 32
# → misalnya: 3kP9x...Q= (salin ke .env.local)
# .env.local (JANGAN di-commit)
SESSION_SECRET=3kP9x...Q=
ADMIN_PASSWORD=rahasia-belajar-123
Modul session: membuat, memverifikasi, dan menghapus token.
// lib/session.js
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
export async function encrypt(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(token) {
if (!token) return null
try {
const { payload } = await jwtVerify(token, encodedKey, { algorithms: ['HS256'] })
return payload // → { role: 'admin', expiresAt: '...', iat: ..., exp: ... }
} catch {
return null // token rusak, dipalsukan, atau kedaluwarsa
}
}
export async function createSession(role) {
const expiresAt = new Date(Date.now() + SEVEN_DAYS)
const token = await encrypt({ role, expiresAt })
const cookieStore = await cookies() // async di Next.js 15+
cookieStore.set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
})
}
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
Server Action untuk login dan logout. Karena proyek akhir hanya punya satu admin, password admin disimpan di environment variable dan dibandingkan dengan waktu konstan (timingSafeEqual) agar penyerang tidak bisa menebak dari lamanya respons.
// app/actions/auth.js
'use server'
import { createHash, timingSafeEqual } from 'node:crypto'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { createSession, deleteSession } from '@/lib/session'
const LoginSchema = z.object({
password: z.string().min(1, 'Password wajib diisi'),
})
// Hash dulu supaya panjangnya sama, lalu bandingkan dengan waktu konstan
function safeEqual(a, b) {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
}
export async function login(prevState, formData) {
const parsed = LoginSchema.safeParse({ password: formData.get('password') })
if (!parsed.success) {
return { error: z.flattenError(parsed.error).fieldErrors.password?.[0] }
}
if (!safeEqual(parsed.data.password, process.env.ADMIN_PASSWORD ?? '')) {
return { error: 'Password salah' } // pesan umum, jangan bocorkan detail
}
await createSession('admin')
redirect('/admin')
}
export async function logout() {
await deleteSession()
redirect('/login')
}
Form login (Client Component) dan tombol logout:
// app/login/LoginForm.jsx
'use client'
import { useActionState } from 'react'
import { login } from '@/app/actions/auth'
import { SubmitButton } from '@/components/SubmitButton'
export function LoginForm() {
const [state, formAction] = useActionState(login, undefined)
return (
<form action={formAction}>
<input type="password" name="password" placeholder="Password admin" required />
{state?.error && <p aria-live="polite">{state.error}</p>}
<SubmitButton pendingText="Memeriksa...">Masuk</SubmitButton>
</form>
)
}
// Di halaman admin (Server Component), logout cukup form kecil:
// <form action={logout}><button>Logout</button></form>
Data Access Layer dengan verifyAdmin yang dipakai halaman dan action:
// lib/dal.js (potongan)
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decrypt } from '@/lib/session'
export const verifyAdmin = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token)
if (session?.role !== 'admin') redirect('/login')
return { role: 'admin' }
})
export async function getAllPostsForAdmin() {
await verifyAdmin() // data sensitif → cek dulu
// ... query DB
}
Halaman terproteksi. Karena cookies() adalah data request, bagian yang membacanya dibungkus <Suspense>:
// app/admin/page.js
import { Suspense } from 'react'
import { getAllPostsForAdmin } from '@/lib/dal'
import { logout } from '@/app/actions/auth'
import { PostRows } from '@/components/PostRows'
async function AdminPosts() {
const posts = await getAllPostsForAdmin() // redirect ke /login bila bukan admin
return <PostRows posts={posts} />
}
export default function AdminPage() {
return (
<section>
<h1>Dashboard</h1>
<form action={logout}><button>Logout</button></form>
<Suspense fallback={<p>Memuat daftar artikel...</p>}>
<AdminPosts />
</Suspense>
</section>
)
}
Versi produksi dengan Better Auth (gambaran)
Untuk aplikasi dengan banyak pengguna terdaftar, berikut bentuk dasar Better Auth 1.7 sesuai dokumentasinya. Pasang dengan npm install better-auth, set BETTER_AUTH_SECRET dan BETTER_AUTH_URL di .env.local, lalu jalankan npx auth@latest generate untuk membuat tabel user/session di schema Drizzle-mu.
// lib/auth.js
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { db } from '@/lib/db'
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: { enabled: true },
plugins: [nextCookies()], // wajib plugin terakhir: agar cookie ter-set dari Server Action
})
// app/api/auth/[...all]/route.js → semua endpoint auth (login, logout, dsb.)
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)
// Di Server Component / Server Action
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
const session = await auth.api.getSession({ headers: await headers() })
// → null bila belum login, atau { user: { id, name, email, ... }, session: {...} }
Di sisi client, createAuthClient dari better-auth/react menyediakan signIn.email(...), signUp.email(...), dan signOut(). Prinsip DAL tetap sama: bungkus auth.api.getSession di dalam verifySession() dan panggil di setiap action.
Kesalahan umum
- Hanya melindungi di UI atau di layout. Tombol tersembunyi dan cek di
layout.jstidak melindungi Server Action yang bisa dipanggil langsung. PanggilverifySession()di setiap action dan sebelum mengambil data sensitif. - Menyimpan data sensitif di JWT. Payload JWT bisa dibaca siapa pun. Simpan hanya ID dan peran; ambil data lain dari database.
- Cookie session tanpa
httpOnly. Script jahat hasil XSS bisa mencurinya. SelaluhttpOnly: true,secure: truedi produksi, dansameSite: 'lax'. - Secret lemah atau ikut di-commit.
SESSION_SECRET=123mudah ditebak. Buat denganopenssl rand -base64 32dan simpan di.env.localserta pengaturan environment hosting. - Mencoba
cookies().set()di Server Component. Tidak didukung, karena cookie harus dikirim lewat header sebelum render di-stream. Set dan hapus cookie hanya di Server Action atau Route Handler.
Latihan
- Mudah, Tampilkan teks "Login sebagai admin" di header situs bila cookie session valid. Gunakan komponen async kecil yang dibungkus
<Suspense>dilayout.js. - Sedang, Tambahkan fungsi
updateSession()yang memperpanjang masa berlaku cookie 7 hari setiap kali admin membuka dashboard, dengan memanggilnya dari Server Action "Tetap login". - Menantang, Ubah sistem login menjadi multi-user: tabel
users(id, email, passwordHash, role) dengan password di-hash memakaibcryptjs(3.0.3). Session menyimpanuserIddanrole, laluverifySession()mengembalikan keduanya.
Lihat solusi
// 1. components/AdminBadge.jsx (Server Component)
import { cookies } from 'next/headers'
import { decrypt } from '@/lib/session'
export async function AdminBadge() {
const session = await decrypt((await cookies()).get('session')?.value)
return session?.role === 'admin' ? <span>Login sebagai admin</span> : null
}
// app/layout.js → di dalam <header>:
// <Suspense fallback={null}><AdminBadge /></Suspense>
// 2. lib/session.js
export async function updateSession() {
const cookieStore = await cookies()
const payload = await decrypt(cookieStore.get('session')?.value)
if (!payload) return null
const expiresAt = new Date(Date.now() + SEVEN_DAYS)
cookieStore.set('session', await encrypt({ role: payload.role, expiresAt }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
})
}
// app/actions/auth.js
export async function stayLoggedIn() {
await verifyAdmin()
await updateSession()
}
// 3a. db/schema.js
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
role: text('role').notNull().default('user'),
})
// 3b. app/actions/auth.js
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
export async function login(prevState, formData) {
const email = String(formData.get('email') ?? '')
const password = String(formData.get('password') ?? '')
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1)
const ok = user && (await bcrypt.compare(password, user.passwordHash))
if (!ok) return { error: 'Email atau password salah' }
await createSession({ userId: user.id, role: user.role })
redirect('/dashboard')
}
// 3c. lib/session.js → createSession(data) menandatangani { ...data, expiresAt }
// 3d. lib/dal.js
export const verifySession = cache(async () => {
const session = await decrypt((await cookies()).get('session')?.value)
if (!session?.userId) redirect('/login')
return { userId: session.userId, role: session.role }
})
Ringkasan
- Autentikasi menjawab "kamu siapa", otorisasi menjawab "boleh apa", session membuat server "ingat" kamu.
- Stateless session (JWT di cookie) sederhana dan cepat; database session bisa dicabut kapan saja.
- Cookie session wajib
httpOnly,securedi produksi,sameSite: 'lax';cookies()di Next.js 16 adalah async. - Pusatkan pengecekan di Data Access Layer (
verifySession+cache) dan panggil di setiap Server Action. - Dokumentasi Next.js menganjurkan library auth untuk produksi; materi ini memakai
joseuntuk belajar dan Better Auth sebagai pilihan produksi, dengan Auth.js dan Clerk sebagai alternatif.