Pendahuluan: Kenapa Autentikasi di Next.js 16 Berbeda dari Sebelumnya
Kalau Anda sudah cukup lama berkutat dengan Next.js, pasti ingat masa-masa di mana proteksi rute itu "cukup" ditangani di middleware.ts. Cek session cookie di middleware, redirect kalau belum login, selesai. Simpel, elegan, dan — sayangnya — tidak cukup aman.
Jujur, saya sendiri pernah merasa bahwa pendekatan itu sudah lebih dari cukup. Sampai semuanya berubah.
Pada Maret 2025, komunitas keamanan dikejutkan oleh CVE-2025-29927, kerentanan kritis pada Next.js yang memungkinkan penyerang mem-bypass middleware sepenuhnya — cukup dengan mengirimkan header x-middleware-subrequest yang dimanipulasi. Bayangkan dampaknya: seluruh aplikasi yang mengandalkan middleware sebagai satu-satunya lapisan autentikasi langsung terbuka lebar. Dashboard admin, data sensitif pengguna, endpoint API internal — semuanya bisa diakses tanpa login. Ngeri, kan?
Kerentanan ini jadi momen "wake-up call" bagi ekosistem Next.js. Dan bukan kebetulan kalau Next.js 16 kemudian memperkenalkan perubahan arsitektural yang cukup fundamental: penggantian middleware.ts menjadi proxy.ts, pergeseran ke Node.js runtime, dan — yang paling penting — adopsi filosofi defense-in-depth sebagai standar keamanan resmi.
Dalam panduan ini, kita akan membangun sistem autentikasi dan otorisasi yang lengkap dan berlapis di Next.js 16. Mulai dari konfigurasi proxy.ts untuk routing, integrasi Auth.js v5 (NextAuth v5), implementasi Data Access Layer (DAL) sebagai jantung keamanan aplikasi, sampai strategi defense-in-depth yang siap produksi.
Oke, langsung saja kita mulai.
proxy.ts: Penerus middleware.ts di Next.js 16
Apa yang Berubah dan Kenapa?
Perubahan paling mencolok di Next.js 16 untuk aspek keamanan adalah penggantian middleware.ts menjadi proxy.ts. Tapi ini bukan sekadar rename — ada pergeseran filosofis yang cukup dalam di baliknya.
Di versi-versi sebelumnya, middleware.ts berjalan di Edge Runtime, lingkungan ringan yang cepat tapi terbatas. Anda nggak bisa pakai banyak library Node.js, koneksi database langsung, atau operasi I/O yang kompleks. Keterbatasan ini sering mendorong developer untuk "mengakali" dengan memasukkan logika autentikasi yang terlalu banyak ke dalam middleware — sesuatu yang seharusnya tidak dilakukan.
proxy.ts di Next.js 16 berjalan di Node.js runtime. Artinya Anda punya akses penuh ke ekosistem Node.js. Tapi — dan ini bagian pentingnya — perannya tetap sama: proxy-level routing. Penggantian nama dari "middleware" ke "proxy" bukan tanpa alasan. Nama baru ini secara eksplisit mengkomunikasikan bahwa file ini seharusnya menangani hal-hal tingkat jaringan seperti redirects, rewrites, dan manipulasi header. Bukan logika bisnis. Bukan verifikasi autentikasi mendalam.
Migrasi dari middleware.ts ke proxy.ts
Tim Next.js menyediakan codemod untuk migrasi otomatis. Tinggal jalankan perintah berikut di root proyek:
npx @next/codemod@latest upgrade
Codemod ini akan otomatis me-rename middleware.ts menjadi proxy.ts dan menyesuaikan import yang diperlukan. Tapi kalau Anda prefer migrasi manual, prosesnya cukup straightforward:
- Rename
middleware.ts(ataumiddleware.js) menjadiproxy.ts(atauproxy.js) - Pastikan konfigurasi
matchermasih sesuai - Pindahkan logika autentikasi yang "berat" ke Data Access Layer (nanti kita bahas detail)
- Sisakan hanya logika routing dasar di
proxy.ts
Contoh proxy.ts untuk Redirect Autentikasi Dasar
Berikut contoh proxy.ts yang tepat — hanya melakukan pengecekan ringan terhadap keberadaan session cookie dan mengarahkan user belum login ke halaman login:
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Daftar path yang membutuhkan autentikasi
const protectedPaths = ["/dashboard", "/settings", "/profile"];
// Path yang hanya boleh diakses user yang BELUM login
const authPaths = ["/login", "/register"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionCookie = request.cookies.get("authjs.session-token")?.value;
// Redirect ke login jika mengakses halaman terproteksi tanpa session
const isProtected = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (isProtected && !sessionCookie) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect ke dashboard jika sudah login tapi mengakses halaman auth
const isAuthPath = authPaths.some((path) =>
pathname.startsWith(path)
);
if (isAuthPath && sessionCookie) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt).*)",
],
};
Perhatikan: yang kita lakukan di sini hanyalah mengecek keberadaan cookie, bukan memverifikasi validitasnya. Ini disengaja. Verifikasi session yang sesungguhnya — apakah token masih valid, apakah user masih aktif, apakah role-nya sesuai — semua itu ditangani di lapisan yang lebih dalam. Layout, Server Components, dan Data Access Layer. Itulah inti dari filosofi defense-in-depth.
Yang harus dilakukan di proxy.ts:
- Redirect berdasarkan keberadaan cookie (bukan validasinya)
- Rewrite URL untuk internationalization (i18n)
- Menambahkan security headers (CSP, HSTS, dll)
- Rate limiting dasar berbasis IP
- A/B testing routing
Yang TIDAK boleh dilakukan di proxy.ts:
- Verifikasi JWT atau session token secara penuh
- Query database untuk mengecek role atau permission
- Logika otorisasi yang kompleks
- Menjadikannya satu-satunya lapisan keamanan
Auth.js v5 (NextAuth v5) di Next.js 16
Instalasi dan Setup Awal
Auth.js v5 (sebelumnya NextAuth.js) adalah library autentikasi de facto untuk ekosistem Next.js. Versi 5 membawa perubahan besar yang selaras dengan arsitektur App Router dan Server Components. Yuk, kita setup dari nol.
Pertama, instal dependencies yang diperlukan:
npm install next-auth@5 @auth/prisma-adapter
Kalau Anda menggunakan adapter database lain (Drizzle, Supabase, dll), ganti @auth/prisma-adapter dengan adapter yang sesuai. Di panduan ini kita pakai Prisma sebagai contoh.
Konfigurasi auth.ts
File konfigurasi utama Auth.js ditempatkan di root proyek (atau folder src/) dengan nama auth.ts. File ini mengekspor fungsi-fungsi utama yang akan digunakan di seluruh aplikasi:
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import bcrypt from "bcryptjs";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt", // Atau "database" untuk session berbasis database
},
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const validated = loginSchema.safeParse(credentials);
if (!validated.success) return null;
const user = await prisma.user.findUnique({
where: { email: validated.data.email },
});
if (!user || !user.hashedPassword) return null;
const isValid = await bcrypt.compare(
validated.data.password,
user.hashedPassword
);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Tambahkan role ke JWT saat pertama kali login
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
// Tambahkan data dari JWT ke objek session
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
},
});
Ada beberapa hal penting dari konfigurasi di atas yang perlu dicatat:
- JWT vs Database session: Strategi
jwtmenyimpan session di cookie yang dienkripsi. Strategidatabasemenyimpan session di database. JWT lebih cepat (nggak perlu query database tiap request), tapi database session memberikan kontrol lebih — misalnya bisa invalidate session secara spesifik. Untuk mayoritas aplikasi, JWT sudah lebih dari cukup. - Callbacks: Callback
jwtdansessiondipakai buat menambahkan data custom (sepertirole) ke token dan session. Tanpa ini, session default cuma berisiname,email, danimage. - Credentials provider: Kalau Anda pakai login email/password, selalu validasi input dengan Zod dan hash password pakai bcrypt. Jangan pernah simpan password plain text — ini harusnya sudah jelas, tapi saya tetap sebutin karena masih sering ditemukan di kode production.
Environment Variables
Auth.js v5 menggunakan prefix AUTH_ untuk semua environment variable-nya. Buat file .env.local dengan isi berikut:
# .env.local
# Secret untuk enkripsi JWT — wajib di production
# Generate dengan: npx auth secret
AUTH_SECRET=your-generated-secret-here
# GitHub OAuth
AUTH_GITHUB_ID=your-github-client-id
AUTH_GITHUB_SECRET=your-github-client-secret
# Google OAuth
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
# URL aplikasi
AUTH_URL=http://localhost:3000
Satu hal yang sering bikin bingung developer baru: di Auth.js v5, AUTH_SECRET wajib diset di environment production. Di development, Auth.js bisa generate secret otomatis — tapi jangan pernah andalkan itu. Selalu set secara eksplisit.
Setup Route Handler
Auth.js membutuhkan route handler untuk menangani flow OAuth (redirect, callback, signout). Buat file berikut:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Ya, sesederhana itu. Cuma dua baris. Objek handlers yang diekspor dari auth.ts sudah berisi semua route handler yang diperlukan. Next.js akan otomatis menangani rute /api/auth/signin, /api/auth/callback/github, /api/auth/signout, dan lain-lain.
Menggunakan auth() di Server Components
Ini salah satu fitur paling powerful dari Auth.js v5 — fungsi auth() yang bisa dipanggil langsung di Server Components. Tidak perlu context, tidak perlu hook. Panggil saja dan dapatkan session:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div>
<h1>Selamat datang, {session.user.name}!</h1>
<p>Role Anda: {session.user.role}</p>
</div>
);
}
Fungsi auth() mengembalikan objek session jika user terautentikasi, atau null jika tidak. Di balik layar, Auth.js membaca dan memverifikasi session cookie secara otomatis. Yang penting: panggilan auth() ini terjadi di server, jadi data session nggak pernah terekspos ke client kecuali Anda secara eksplisit mengirimkannya.
SessionProvider untuk Client Components
Untuk mengakses session di Client Components, Anda butuh SessionProvider. Setup ini di layout root:
// app/layout.tsx
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
return (
<html lang="id">
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
);
}
Lalu di Client Component manapun, gunakan hook useSession:
// components/user-menu.tsx
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Memuat...</div>;
}
if (!session) {
return (
<button onClick={() => signIn()}>
Masuk
</button>
);
}
return (
<div>
<span>Halo, {session.user.name}</span>
<button onClick={() => signOut()}>
Keluar
</button>
</div>
);
}
Server Actions untuk Sign In dan Sign Out
Auth.js v5 juga mengekspor fungsi signIn dan signOut yang bisa digunakan sebagai Server Actions. Ini sangat berguna kalau Anda mau membangun form login custom tanpa bergantung halaman bawaan Auth.js:
// app/actions/auth.ts
"use server";
import { signIn, signOut } from "@/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
export async function loginWithGitHub() {
await signIn("github", { redirectTo: "/dashboard" });
}
export async function loginWithGoogle() {
await signIn("google", { redirectTo: "/dashboard" });
}
export async function loginWithCredentials(
prevState: { error?: string } | undefined,
formData: FormData
) {
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirect: false,
});
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: "Email atau password salah." };
default:
return { error: "Terjadi kesalahan. Silakan coba lagi." };
}
}
throw error;
}
redirect("/dashboard");
}
export async function logout() {
await signOut({ redirectTo: "/login" });
}
Dengan pendekatan ini, Anda bisa bikin halaman login yang sepenuhnya custom sambil tetap mendapatkan semua keamanan yang ditangani library. Menurut saya, ini salah satu keunggulan terbesar Auth.js v5 dibanding versi sebelumnya.
Proteksi Rute dengan Layout
Kenapa Layout Lebih Baik dari Middleware untuk Auth?
Ini mungkin terdengar kontroversial, tapi layout adalah tempat yang lebih tepat untuk proteksi rute dibandingkan middleware. Kenapa? Ada beberapa alasan kuat yang mungkin mengubah cara pikir Anda:
- Keamanan: Setelah CVE-2025-29927, kita tahu middleware bisa di-bypass. Layout yang memanggil
auth()secara langsung tidak bisa di-bypass karena berjalan sebagai bagian dari rendering server. - Verifikasi penuh: Di layout, Anda bisa melakukan verifikasi session yang lengkap — termasuk mengecek apakah session masih valid, user belum di-ban, atau role masih sesuai. Bukan cuma cek keberadaan cookie.
- Akses ke database: Layout di App Router adalah Server Components, jadi Anda bisa langsung query database untuk verifikasi yang lebih mendalam.
- Colocation: Logika proteksi ditempatkan dekat dengan kode yang dilindungi. Ini bikin codebase lebih mudah dipahami dan di-maintain.
Pattern: Protected Layout
Buat layout khusus untuk semua rute yang membutuhkan autentikasi:
// app/(protected)/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect("/login");
}
// Opsional: cek apakah akun masih aktif
// const user = await getUserById(session.user.id);
// if (user.status === "banned") redirect("/account-suspended");
return (
<div>
<nav>
{/* Navigation untuk user yang sudah login */}
<span>{session.user.name}</span>
</nav>
<main>{children}</main>
</div>
);
}
Semua halaman di dalam route group (protected) otomatis dilindungi oleh layout ini. Struktur foldernya kira-kira seperti ini:
app/
├── (protected)/
│ ├── layout.tsx # Layout dengan auth check
│ ├── dashboard/
│ │ └── page.tsx # Otomatis terproteksi
│ ├── settings/
│ │ └── page.tsx # Otomatis terproteksi
│ └── profile/
│ └── page.tsx # Otomatis terproteksi
├── (auth)/
│ ├── layout.tsx # Layout untuk halaman auth
│ ├── login/
│ │ └── page.tsx
│ └── register/
│ └── page.tsx
└── layout.tsx # Root layout
Layout dengan Role-Based Access
Untuk area yang butuh role tertentu (misalnya admin panel), buat layout tambahan yang lebih spesifik:
// app/(protected)/admin/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
// Protected layout di atasnya sudah mengecek session != null
// Di sini kita tambahkan pengecekan role
if (session?.user.role !== "admin") {
redirect("/dashboard"); // Atau halaman "unauthorized"
}
return (
<div>
<aside>
{/* Sidebar admin */}
</aside>
<main>{children}</main>
</div>
);
}
Dengan nesting layout seperti ini, Anda mendapatkan proteksi berlapis yang alami: layout (protected) memastikan user sudah login, dan layout admin di dalamnya memastikan user punya role yang tepat. Rapi, intuitif, dan gampang di-debug.
Data Access Layer (DAL): Jantung Keamanan Aplikasi
Apa Itu Data Access Layer dan Kenapa Wajib Ada?
Data Access Layer (DAL) adalah pola arsitektur di mana semua akses ke database dibungkus dalam fungsi-fungsi yang memverifikasi autentikasi dan otorisasi sebelum mengeksekusi query. Konsep ini sebenarnya bukan hal baru — pattern ini sudah bertahun-tahun digunakan di enterprise software. Yang baru adalah rekomendasi resmi dari tim Next.js untuk mengadopsinya sebagai lapisan keamanan utama di aplikasi modern.
Kenapa DAL begitu penting? Karena di sinilah keamanan yang sesungguhnya terjadi.
Proxy bisa di-bypass (sudah terbukti). Layout bisa terlewat kalau ada refactoring yang nggak hati-hati. Tapi kalau setiap fungsi yang menyentuh database selalu memverifikasi autentikasi dan otorisasi — nggak peduli dipanggil dari mana — maka data Anda benar-benar aman. Titik.
Setup Dasar: lib/dal.ts dengan server-only
Langkah pertama yang krusial: pastikan file DAL Anda hanya bisa diimpor di server. Gunakan package server-only untuk ini:
npm install server-only
// lib/dal.ts
import "server-only";
import { auth } from "@/auth";
import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
// Memoize verifikasi auth dalam satu render pass
// Ini mencegah multiple database calls untuk auth check
// yang sama dalam satu request
export const verifySession = cache(async () => {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return {
isAuth: true,
userId: session.user.id,
userRole: session.user.role,
};
});
Ada dua hal penting di sini yang perlu diperhatikan:
import "server-only": Import ini memastikan bahwa kalau ada developer yang secara nggak sengaja mengimpor file DAL di Client Component, build process bakal langsung error. Ini bukan nice-to-have — ini wajib. Tanpa ini, ada risiko logika akses database bocor ke client bundle. Saya pernah lihat ini terjadi di production dan akibatnya... nggak menyenangkan.cache()dari React: Fungsicache()memoize hasil pemanggilan dalam satu render pass. Artinya, kalauverifySession()dipanggil 5 kali dalam satu request (dari layout, page, dan beberapa komponen), fungsi sebenarnya cuma dieksekusi sekali. Sisanya pakai hasil yang sudah di-cache. Penting banget buat performa.
Data Transfer Objects (DTO): Jangan Pernah Return Raw Database Models
Sebelum masuk ke contoh lengkap, ada satu prinsip yang harus dipegang teguh: fungsi DAL harus mengembalikan DTO (Data Transfer Objects), bukan raw database models. Kenapa?
- Raw database models mungkin berisi field sensitif (hashed password, internal IDs, metadata internal)
- Kalau Anda menambahkan kolom baru ke database, kolom itu otomatis terekspos tanpa review
- DTO memaksa Anda untuk secara eksplisit memilih data apa yang boleh diakses
// lib/dto.ts
import "server-only";
// DTO untuk profil user — hanya data yang aman untuk ditampilkan
export type UserProfileDTO = {
id: string;
name: string;
email: string;
role: string;
avatarUrl: string | null;
createdAt: Date;
};
// DTO untuk post — tanpa field internal
export type PostDTO = {
id: string;
title: string;
content: string;
authorName: string;
createdAt: Date;
updatedAt: Date;
};
// DTO untuk post di daftar — lebih ringkas
export type PostListItemDTO = {
id: string;
title: string;
excerpt: string;
authorName: string;
createdAt: Date;
};
Contoh Lengkap: DAL untuk User Profile
// lib/dal/users.ts
import "server-only";
import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { verifySession } from "@/lib/dal";
import type { UserProfileDTO } from "@/lib/dto";
// Ambil profil user yang sedang login
export const getCurrentUserProfile = cache(
async (): Promise<UserProfileDTO> => {
const { userId } = await verifySession();
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
role: true,
avatarUrl: true,
createdAt: true,
// TIDAK menyertakan: hashedPassword, internalNotes, dll.
},
});
return user;
}
);
// Ambil profil user lain (publik)
export async function getPublicUserProfile(
userId: string
): Promise<UserProfileDTO | null> {
// Untuk profil publik, kita tetap verifikasi bahwa pemanggil
// adalah user yang terautentikasi
await verifySession();
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: false, // Email tidak ditampilkan di profil publik
role: true,
avatarUrl: true,
createdAt: true,
},
});
if (!user) return null;
return {
...user,
email: "", // Kosongkan email di profil publik
};
}
// Update profil — hanya bisa update profil sendiri
export async function updateUserProfile(data: {
name?: string;
avatarUrl?: string;
}): Promise<UserProfileDTO> {
const { userId } = await verifySession();
const updated = await prisma.user.update({
where: { id: userId },
data,
select: {
id: true,
name: true,
email: true,
role: true,
avatarUrl: true,
createdAt: true,
},
});
return updated;
}
Contoh Lengkap: DAL untuk Posts dengan CRUD dan Otorisasi
// lib/dal/posts.ts
import "server-only";
import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { verifySession } from "@/lib/dal";
import type { PostDTO, PostListItemDTO } from "@/lib/dto";
// Ambil semua post (publik, terautentikasi)
export const getAllPosts = cache(
async (): Promise<PostListItemDTO[]> => {
await verifySession();
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
orderBy: { createdAt: "desc" },
});
// Transform ke DTO
return posts.map((post) => ({
id: post.id,
title: post.title,
excerpt: post.content.substring(0, 200) + "...",
authorName: post.author.name ?? "Anonim",
createdAt: post.createdAt,
}));
}
);
// Ambil post milik user yang login
export const getMyPosts = cache(
async (): Promise<PostListItemDTO[]> => {
const { userId } = await verifySession();
const posts = await prisma.post.findMany({
where: { authorId: userId },
include: { author: { select: { name: true } } },
orderBy: { createdAt: "desc" },
});
return posts.map((post) => ({
id: post.id,
title: post.title,
excerpt: post.content.substring(0, 200) + "...",
authorName: post.author.name ?? "Anonim",
createdAt: post.createdAt,
}));
}
);
// Ambil satu post berdasarkan ID
export async function getPostById(
postId: string
): Promise<PostDTO | null> {
await verifySession();
const post = await prisma.post.findUnique({
where: { id: postId, published: true },
include: { author: { select: { name: true } } },
});
if (!post) return null;
return {
id: post.id,
title: post.title,
content: post.content,
authorName: post.author.name ?? "Anonim",
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
// Buat post baru
export async function createPost(data: {
title: string;
content: string;
}): Promise<PostDTO> {
const { userId } = await verifySession();
const post = await prisma.post.create({
data: {
...data,
authorId: userId,
published: false, // Default draft
},
include: { author: { select: { name: true } } },
});
return {
id: post.id,
title: post.title,
content: post.content,
authorName: post.author.name ?? "Anonim",
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
// Update post — hanya pemilik atau admin
export async function updatePost(
postId: string,
data: { title?: string; content?: string; published?: boolean }
): Promise<PostDTO> {
const { userId, userRole } = await verifySession();
// Cek kepemilikan
const existingPost = await prisma.post.findUniqueOrThrow({
where: { id: postId },
});
if (existingPost.authorId !== userId && userRole !== "admin") {
throw new Error("Anda tidak memiliki akses untuk mengedit post ini");
}
const updated = await prisma.post.update({
where: { id: postId },
data,
include: { author: { select: { name: true } } },
});
return {
id: updated.id,
title: updated.title,
content: updated.content,
authorName: updated.author.name ?? "Anonim",
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
};
}
// Hapus post — hanya pemilik atau admin
export async function deletePost(postId: string): Promise<void> {
const { userId, userRole } = await verifySession();
const existingPost = await prisma.post.findUniqueOrThrow({
where: { id: postId },
});
if (existingPost.authorId !== userId && userRole !== "admin") {
throw new Error("Anda tidak memiliki akses untuk menghapus post ini");
}
await prisma.post.delete({ where: { id: postId } });
}
Perhatikan pola konsistennya: setiap fungsi dimulai dengan verifikasi session. Tanpa pengecualian. Bahkan fungsi yang "cuma" membaca data publik tetap memverifikasi bahwa pemanggil sudah terautentikasi. Terasa berlebihan? Mungkin. Tapi lebih baik overprotect daripada ada celah yang terlewat — percaya deh.
Role-Based Access Control (RBAC) di DAL
Untuk aplikasi yang lebih kompleks, Anda mungkin butuh sistem permission yang lebih granular. Berikut contoh implementasi RBAC sederhana tapi efektif di DAL:
// lib/rbac.ts
import "server-only";
// Definisi permission berdasarkan role
const permissions = {
admin: [
"posts:create", "posts:read", "posts:update", "posts:delete",
"users:read", "users:update", "users:delete",
"settings:read", "settings:update",
],
editor: [
"posts:create", "posts:read", "posts:update",
"users:read",
],
user: [
"posts:create", "posts:read",
"users:read",
],
} as const;
type Role = keyof typeof permissions;
type Permission = (typeof permissions)[Role][number];
export function hasPermission(
role: string,
permission: Permission
): boolean {
const rolePermissions = permissions[role as Role];
if (!rolePermissions) return false;
return (rolePermissions as readonly string[]).includes(permission);
}
// Helper untuk digunakan di DAL
export async function requirePermission(
userRole: string,
permission: Permission
): Promise<void> {
if (!hasPermission(userRole, permission)) {
throw new Error(
`Akses ditolak: role "${userRole}" tidak memiliki permission "${permission}"`
);
}
}
// Contoh penggunaan di DAL
// lib/dal/admin.ts
import "server-only";
import { verifySession } from "@/lib/dal";
import { requirePermission } from "@/lib/rbac";
import { prisma } from "@/lib/prisma";
export async function getAllUsers() {
const { userRole } = await verifySession();
await requirePermission(userRole, "users:read");
return prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
});
}
export async function deleteUser(targetUserId: string) {
const { userId, userRole } = await verifySession();
await requirePermission(userRole, "users:delete");
// Admin tidak bisa menghapus dirinya sendiri
if (targetUserId === userId) {
throw new Error("Anda tidak bisa menghapus akun Anda sendiri dari sini");
}
await prisma.user.delete({ where: { id: targetUserId } });
}
Yang saya suka dari pola ini: menambahkan role baru atau mengubah permission cukup dilakukan di satu file (lib/rbac.ts), dan semua fungsi DAL yang menggunakan requirePermission otomatis mengikuti perubahannya. Nggak perlu hunting di puluhan file.
Mengamankan Server Actions
Verifikasi Auth di Setiap Server Action
Hal yang sering dilupakan developer: setiap fungsi yang ditandai "use server" adalah endpoint HTTP publik. Siapa pun bisa mengirim POST request ke endpoint tersebut — nggak perlu lewat UI Anda. Maka, setiap Server Action harus memverifikasi autentikasi dan otorisasi. Tanpa pengecualian.
Kabar baiknya, kalau Anda sudah mengimplementasikan DAL, pekerjaan ini jadi jauh lebih ringan. Server Action cukup handle validasi input lalu panggil fungsi DAL yang sudah mengurus auth:
// app/actions/posts.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
createPost as dalCreatePost,
updatePost as dalUpdatePost,
deletePost as dalDeletePost,
} from "@/lib/dal/posts";
const CreatePostSchema = z.object({
title: z.string().min(3, "Judul minimal 3 karakter").max(200),
content: z.string().min(50, "Konten minimal 50 karakter").max(50000),
});
const UpdatePostSchema = z.object({
postId: z.string().cuid(),
title: z.string().min(3).max(200).optional(),
content: z.string().min(50).max(50000).optional(),
published: z.boolean().optional(),
});
export type PostActionState = {
errors?: Record<string, string[]>;
message?: string;
success?: boolean;
};
export async function createPost(
prevState: PostActionState,
formData: FormData
): Promise<PostActionState> {
// Langkah 1: Validasi input
const validated = CreatePostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Data tidak valid.",
};
}
// Langkah 2: Panggil DAL (auth + otorisasi + mutasi)
try {
await dalCreatePost(validated.data);
} catch (error) {
return {
message:
error instanceof Error
? error.message
: "Gagal membuat post.",
};
}
// Langkah 3: Revalidasi dan redirect
revalidatePath("/dashboard/posts");
redirect("/dashboard/posts");
}
export async function updatePost(
prevState: PostActionState,
formData: FormData
): Promise<PostActionState> {
const validated = UpdatePostSchema.safeParse({
postId: formData.get("postId"),
title: formData.get("title") || undefined,
content: formData.get("content") || undefined,
published: formData.get("published") === "true" ? true : undefined,
});
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Data tidak valid.",
};
}
const { postId, ...data } = validated.data;
try {
await dalUpdatePost(postId, data);
} catch (error) {
return {
message:
error instanceof Error
? error.message
: "Gagal mengupdate post.",
};
}
revalidatePath("/dashboard/posts");
return { success: true, message: "Post berhasil diupdate." };
}
export async function deletePost(postId: string): Promise<PostActionState> {
// Validasi input sederhana
const validated = z.string().cuid().safeParse(postId);
if (!validated.success) {
return { message: "ID post tidak valid." };
}
try {
await dalDeletePost(validated.data);
} catch (error) {
return {
message:
error instanceof Error
? error.message
: "Gagal menghapus post.",
};
}
revalidatePath("/dashboard/posts");
return { success: true, message: "Post berhasil dihapus." };
}
Rate Limiting pada Auth Endpoints
Endpoint autentikasi itu target utama brute-force attacks. Tanpa rate limiting, penyerang bisa mencoba ribuan kombinasi password per menit. Jadi, implementasikan rate limiting — setidaknya untuk endpoint login:
// lib/rate-limit.ts
import "server-only";
// Rate limiter sederhana berbasis memory
// Di production, gunakan Redis atau solusi yang lebih robust
const rateLimitMap = new Map<
string,
{ count: number; lastReset: number }
>();
export function rateLimit(
key: string,
limit: number,
windowMs: number
): { success: boolean; remaining: number } {
const now = Date.now();
const record = rateLimitMap.get(key);
if (!record || now - record.lastReset > windowMs) {
rateLimitMap.set(key, { count: 1, lastReset: now });
return { success: true, remaining: limit - 1 };
}
if (record.count >= limit) {
return { success: false, remaining: 0 };
}
record.count++;
return { success: true, remaining: limit - record.count };
}
// Penggunaan di Server Action login
"use server";
import { headers } from "next/headers";
import { rateLimit } from "@/lib/rate-limit";
export async function loginWithCredentials(
prevState: { error?: string } | undefined,
formData: FormData
) {
// Rate limiting berdasarkan IP
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") ?? "unknown";
const { success } = rateLimit(
`login:${ip}`,
5, // Maksimal 5 percobaan
60000 // Per menit
);
if (!success) {
return {
error: "Terlalu banyak percobaan login. Coba lagi dalam 1 menit.",
};
}
// ... lanjutkan dengan logika login
}
Strategi Defense-in-Depth Lengkap
Arsitektur Keamanan Berlapis
Nah, sekarang saatnya menyatukan semua komponen yang sudah kita bahas ke dalam satu strategi keamanan yang koheren. Defense-in-depth bukan sekadar buzzword — ini prinsip arsitektural di mana setiap lapisan keamanan berdiri sendiri dan tidak bergantung pada lapisan lainnya. Kalau satu lapisan gagal atau di-bypass, lapisan berikutnya tetap melindungi aplikasi.
Berikut visualisasi arsitektur defense-in-depth di Next.js 16:
┌─────────────────────────────────────────────────┐
│ REQUEST MASUK │
└─────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LAPISAN 1: proxy.ts │
│ ───────────────────────────── │
│ • Cek keberadaan session cookie │
│ • Redirect ke /login jika tidak ada cookie │
│ • Redirect user yang sudah login dari /login │
│ • Tambahkan security headers │
│ • Rate limiting dasar │
│ ───────────────────────────── │
│ CATATAN: Hanya routing, BUKAN verifikasi auth │
└─────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LAPISAN 2: Layout (Server Component) │
│ ───────────────────────────── │
│ • Panggil auth() untuk verifikasi session │
│ • Redirect jika session invalid │
│ • Cek role untuk area tertentu (admin) │
│ • Cek status akun (aktif/banned) │
│ ───────────────────────────── │
│ CATATAN: Proteksi di level halaman │
└─────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LAPISAN 3: Data Access Layer (DAL) │
│ ───────────────────────────── │
│ • verifySession() di setiap fungsi │
│ • Cek kepemilikan resource (otorisasi) │
│ • RBAC (Role-Based Access Control) │
│ • Return DTO, bukan raw database models │
│ • Dilindungi oleh "server-only" import │
│ ───────────────────────────── │
│ CATATAN: Keamanan di level DATA │
└─────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LAPISAN 4: Server Actions │
│ ───────────────────────────── │
│ • Validasi input dengan Zod │
│ • Rate limiting pada endpoint sensitif │
│ • Memanggil DAL (yang sudah terproteksi) │
│ • Error handling yang aman (tidak leak info) │
│ ───────────────────────────── │
│ CATATAN: Validasi dan sanitasi di level MUTASI │
└─────────────────────────────────────────────────┘
Kenapa Setiap Lapisan Dibutuhkan?
Mungkin Anda bertanya: "Kalau DAL sudah ngecek auth, ngapain masih cek di layout dan proxy?" Pertanyaan yang wajar. Jawabannya simpel: setiap lapisan melindungi dari skenario kegagalan yang berbeda.
- Proxy.ts gagal? (seperti CVE-2025-29927) Layout tetap memverifikasi session. User nggak bisa lihat halaman.
- Layout ter-bypass? (misalnya ada API route yang langsung memanggil fungsi data) DAL tetap memverifikasi auth. Data nggak bocor.
- Developer lupa panggil DAL? (langsung query database di Server Action) Ini memang celah — makanya konsistensi sangat penting. Tapi setidaknya layout sudah memastikan user yang mengakses halaman sudah terautentikasi.
- Semua lapisan di atas bekerja, tapi input nggak divalidasi? Server Action dengan Zod memastikan data yang masuk ke database bersih dan sesuai schema.
Contoh Flow Lengkap: User Menghapus Post
Untuk lebih jelasnya, mari telusuri alur lengkap saat user mengklik tombol "Hapus" pada sebuah post:
// 1. proxy.ts - Request melewati proxy
// → Cookie "authjs.session-token" ada? ✅ Lanjut.
// 2. app/(protected)/dashboard/posts/page.tsx dirender
// → Layout memanggil auth() dan memverifikasi session ✅
// 3. User mengklik tombol hapus, memanggil Server Action
// app/actions/posts.ts
export async function deletePost(postId: string) {
// Validasi input
const validated = z.string().cuid().safeParse(postId);
if (!validated.success) {
return { message: "ID post tidak valid." };
}
// Panggil DAL
try {
await dalDeletePost(validated.data);
// dalDeletePost internally:
// → verifySession() → Cek auth ✅
// → Cek post.authorId === userId ✅ (otorisasi)
// → prisma.post.delete() → Hapus dari database ✅
} catch (error) {
return { message: error.message };
}
revalidatePath("/dashboard/posts");
return { success: true };
}
Empat lapisan keamanan dilewati untuk satu operasi hapus. Ketat? Sangat. Tapi itulah yang bikin aplikasi Anda tahan terhadap serangan.
Best Practices dan Kesalahan Umum
Kesalahan yang Sering Dilakukan
1. Mengandalkan Hanya Middleware/Proxy untuk Autentikasi
Ini kesalahan paling fatal dan (sayangnya) paling sering terjadi. CVE-2025-29927 sudah membuktikan bahwa middleware bisa di-bypass. Jangan pernah jadikan middleware sebagai satu-satunya gerbang keamanan Anda.
// ❌ SALAH: Hanya cek di proxy.ts, data langsung diakses tanpa verifikasi
// proxy.ts
export function middleware(request: NextRequest) {
if (!request.cookies.get("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
// app/dashboard/page.tsx
export default async function Dashboard() {
// Langsung query database tanpa verifikasi auth
const data = await prisma.user.findMany(); // ❌ BERBAHAYA!
return <div>{/* render data */}</div>;
}
// ✅ BENAR: Verifikasi di setiap lapisan
// app/dashboard/page.tsx
export default async function Dashboard() {
const users = await getAllUsers(); // DAL memverifikasi auth + otorisasi
return <div>{/* render data */}</div>;
}
2. Mengekspos Raw Database Models ke Client
// ❌ SALAH: Return raw Prisma model
export async function getUser(id: string) {
return prisma.user.findUnique({ where: { id } });
// Ini mengembalikan hashedPassword, internalNotes, dll!
}
// ✅ BENAR: Return DTO dengan field yang dipilih secara eksplisit
export async function getUser(id: string): Promise<UserProfileDTO> {
const user = await prisma.user.findUniqueOrThrow({
where: { id },
select: { id: true, name: true, email: true, role: true },
});
return user;
}
3. Lupa Import "server-only" di File DAL
Tanpa import "server-only", nggak ada yang mencegah file DAL diimpor di Client Component. Akibatnya? Logika server (termasuk connection string database) bisa masuk ke client bundle. Serem.
// ❌ SALAH: Tidak ada guard
// lib/dal.ts
import { prisma } from "@/lib/prisma";
export async function getSecretData() { ... }
// ✅ BENAR: Ada guard "server-only"
// lib/dal.ts
import "server-only"; // Build error jika diimpor di client
import { prisma } from "@/lib/prisma";
export async function getSecretData() { ... }
4. Tidak Memvalidasi Input di Server Actions
TypeScript types itu hilang saat runtime — mereka cuma ada di compile time. Artinya seseorang bisa mengirim payload apa pun ke Server Action Anda. Selalu validasi dengan Zod atau library serupa.
// ❌ SALAH: Percaya FormData mentah
export async function updateProfile(formData: FormData) {
const name = formData.get("name") as string;
// Langsung pakai tanpa validasi
await db.users.update({ data: { name } });
}
// ✅ BENAR: Validasi dulu
export async function updateProfile(formData: FormData) {
const validated = z.object({
name: z.string().min(1).max(100),
}).safeParse({ name: formData.get("name") });
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors };
}
await dalUpdateProfile(validated.data);
}
5. Tidak Rate Limiting pada Endpoint Auth
Tanpa rate limiting, penyerang bisa melakukan brute-force attack ribuan kali per detik terhadap endpoint login. Implementasikan rate limiting minimal di Server Action login, dan idealnya juga di proxy.ts.
Best Practices yang Harus Diikuti
1. Selalu Verifikasi di Titik Akses Data
Prinsip utamanya: jangan pernah percaya bahwa lapisan sebelumnya sudah mengecek auth. Setiap fungsi yang mengakses atau memodifikasi data harus memverifikasi sendiri. Paranoid? Sedikit. Tapi paranoia yang sehat itu baik di dunia security.
2. Gunakan React cache() untuk Memoize Auth Checks
Kalau Anda khawatir soal performa karena verifySession dipanggil berkali-kali, tenang — gunakan cache() dari React. Dalam satu render pass, fungsi yang di-wrap cache() cuma dieksekusi sekali:
// Dipanggil 10x dalam satu request? Tetap hanya 1x eksekusi.
export const verifySession = cache(async () => {
const session = await auth();
if (!session?.user) redirect("/login");
return { userId: session.user.id, userRole: session.user.role };
});
3. Implementasikan RBAC dari Awal
Jangan tunggu sampai aplikasi butuh multi-role baru bikin sistem permission. Implementasikan RBAC dari awal — walau saat ini cuma ada "user" dan "admin". Menambahkan role baru nanti bakal jauh lebih mudah kalau fondasi sudah ada.
4. Audit Trail untuk Operasi Sensitif
Untuk operasi kritis (hapus user, ubah role, akses data sensitif), simpan audit log. Ini bukan cuma buat security — tapi juga sangat membantu saat debugging di production:
// lib/dal/admin.ts
export async function changeUserRole(
targetUserId: string,
newRole: string
) {
const { userId, userRole } = await verifySession();
await requirePermission(userRole, "users:update");
const oldUser = await prisma.user.findUniqueOrThrow({
where: { id: targetUserId },
});
await prisma.$transaction([
prisma.user.update({
where: { id: targetUserId },
data: { role: newRole },
}),
prisma.auditLog.create({
data: {
action: "CHANGE_ROLE",
performedBy: userId,
targetUser: targetUserId,
details: `Role diubah dari "${oldUser.role}" ke "${newRole}"`,
},
}),
]);
}
5. Pisahkan Error Messages untuk User dan Developer
Jangan pernah tampilkan pesan error internal ke user. Error dari database, stack traces, atau informasi teknis bisa dimanfaatkan penyerang untuk memahami struktur aplikasi Anda.
// ❌ SALAH: Expose error internal
catch (error) {
return { message: error.message };
// Bisa menampilkan "Unique constraint failed on fields: (email)"
}
// ✅ BENAR: Pesan generik untuk user, detail ke log
catch (error) {
console.error("Failed to create user:", error); // Untuk developer
return { message: "Gagal membuat akun. Silakan coba lagi." }; // Untuk user
}
6. Type-Safety untuk Session
Perluas tipe session Auth.js supaya TypeScript mengenali field custom seperti role dan id:
// types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}
Dengan deklarasi tipe ini, TypeScript memberikan autocomplete dan type checking untuk session.user.role dan session.user.id di seluruh aplikasi. Nggak ada lagi as string yang bikin was-was.
Rangkuman: Membangun Benteng Keamanan yang Kokoh
Autentikasi dan otorisasi di Next.js 16 bukan lagi soal satu file middleware yang mengecek cookie. Ini soal membangun sistem keamanan berlapis yang saling mendukung dan melindungi satu sama lain.
Mari kita rekap apa yang sudah kita pelajari:
- proxy.ts menggantikan middleware.ts dengan peran yang lebih jelas: routing-level operations saja. Gunakan untuk redirect dasar, bukan sebagai satu-satunya pertahanan auth.
- Auth.js v5 menyediakan infrastruktur autentikasi yang terintegrasi dengan App Router — dari OAuth providers hingga session management yang aman.
- Protected Layouts jadi lapisan kedua yang memverifikasi session di level halaman, memanfaatkan kekuatan Server Components.
- Data Access Layer (DAL) adalah jantung keamanan — setiap akses data diverifikasi, setiap respons dibungkus dalam DTO, dan semuanya dilindungi
server-only. - Server Actions memvalidasi semua input dengan Zod dan mendelegasikan logika data ke DAL yang sudah terproteksi.
- Defense-in-depth bukan paranoia — ini strategi yang terbukti efektif. CVE-2025-29927 mengajarkan bahwa satu lapisan keamanan nggak pernah cukup.
Membangun semua lapisan ini memang butuh effort lebih di awal. Tapi jujur, biaya untuk memperbaiki kerentanan keamanan setelah data bocor itu jauh — jauh — lebih besar daripada biaya membangun sistem yang aman dari awal. Investasi di keamanan berlapis hari ini adalah ketenangan pikiran Anda besok.
Selamat membangun aplikasi Next.js yang aman!