Autentikasi Modern di 2026: JWT, Session, OAuth, dan Passkey
Lanskap autentikasi lengkap: kapan menggunakan session vs JWT, alur OAuth 2.0/OIDC, rotasi refresh token, passkey (WebAuthn), dan pola autentikasi Next.js yang benar-benar saya gunakan.
Autentikasi adalah satu area pengembangan web di mana "berfungsi" tidak pernah cukup. Bug di date picker Anda itu menjengkelkan. Bug di sistem autentikasi Anda adalah kebocoran data.
Saya pernah mengimplementasikan autentikasi dari nol, bermigrasi antar provider, men-debug insiden pencurian token, dan menghadapi dampak dari keputusan "kita perbaiki keamanannya nanti". Tulisan ini adalah panduan komprehensif yang saya harap ada ketika saya mulai. Bukan hanya teori — melainkan trade-off yang sebenarnya, kerentanan nyata, dan pola-pola yang bertahan di bawah tekanan produksi.
Kita akan membahas seluruh lanskap: session, JWT, OAuth 2.0, passkey, MFA, dan otorisasi. Di akhir tulisan, Anda akan memahami bukan hanya bagaimana setiap mekanisme bekerja, tetapi kapan menggunakannya dan mengapa alternatifnya ada.
Session vs JWT: Trade-off yang Sebenarnya#
Ini adalah keputusan pertama yang akan Anda hadapi, dan internet penuh dengan saran buruk tentang hal ini. Biarkan saya menjelaskan apa yang benar-benar penting.
Autentikasi Berbasis Session#
Session adalah pendekatan awal. Server membuat catatan session, menyimpannya di suatu tempat (basis data, Redis, memori), dan memberikan klien session ID yang tidak transparan dalam cookie.
// Pembuatan session yang disederhanakan
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 jam
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Simpan di basis data atau Redis Anda
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}Keuntungannya nyata:
- Pencabutan instan. Hapus catatan session dan pengguna ter-logout. Tidak perlu menunggu kedaluwarsa. Ini penting ketika Anda mendeteksi aktivitas mencurigakan.
- Visibilitas session. Anda bisa menampilkan session aktif kepada pengguna ("masuk di Chrome, Windows 11, Jakarta") dan membiarkan mereka mencabut masing-masing.
- Ukuran cookie kecil. Session ID biasanya 64 karakter. Cookie tidak pernah membesar.
- Kontrol sisi server. Anda bisa memperbarui data session (menaikkan pengguna menjadi admin, mengubah izin) dan langsung berlaku pada request berikutnya.
Kekurangannya juga nyata:
- Hit basis data di setiap request. Setiap request terautentikasi membutuhkan pencarian session. Dengan Redis ini sub-milidetik, tetapi tetap merupakan dependensi.
- Penskalaan horizontal membutuhkan penyimpanan bersama. Jika Anda memiliki beberapa server, semuanya perlu akses ke penyimpanan session yang sama. Sticky session adalah solusi rapuh.
- CSRF menjadi perhatian. Karena cookie dikirim secara otomatis, Anda membutuhkan perlindungan CSRF. Cookie SameSite sebagian besar menyelesaikan ini, tetapi Anda perlu memahami alasannya.
Autentikasi Berbasis JWT#
JWT membalik modelnya. Alih-alih menyimpan state session di server, Anda mengenkode-nya ke dalam token bertanda tangan yang dipegang klien.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}Keuntungannya:
- Tidak ada penyimpanan sisi server. Token bersifat mandiri. Anda memverifikasi tanda tangan dan membaca klaim. Tidak ada hit basis data.
- Berfungsi lintas layanan. Dalam arsitektur microservices, layanan mana pun dengan kunci publik bisa memverifikasi token. Tidak perlu penyimpanan session bersama.
- Penskalaan stateless. Tambahkan lebih banyak server tanpa khawatir tentang afinitas session.
Kekurangannya — dan inilah yang sering dilewatkan orang:
- Anda tidak bisa mencabut JWT. Setelah diterbitkan, token valid sampai kedaluwarsa. Jika akun pengguna dikompromikan, Anda tidak bisa memaksa logout. Anda bisa membangun blocklist, tetapi kemudian Anda telah memperkenalkan kembali state sisi server dan kehilangan keuntungan utamanya.
- Ukuran token. JWT dengan beberapa klaim biasanya 800+ byte. Tambahkan role, izin, dan metadata dan Anda mengirim kilobyte di setiap request.
- Payload bisa dibaca. Payload di-encode Base64, bukan dienkripsi. Siapa pun bisa mendekode-nya. Jangan pernah menaruh data sensitif di JWT.
- Masalah perbedaan jam. Jika server Anda memiliki jam yang berbeda (itu terjadi), pemeriksaan kedaluwarsa menjadi tidak bisa diandalkan.
Kapan Menggunakan Masing-Masing#
Aturan praktis saya:
Gunakan session ketika: Anda memiliki aplikasi monolitik, Anda membutuhkan pencabutan instan, Anda sedang membangun produk untuk konsumen di mana keamanan akun sangat penting, atau persyaratan autentikasi Anda mungkin sering berubah.
Gunakan JWT ketika: Anda memiliki arsitektur microservices di mana layanan perlu memverifikasi identitas secara independen, Anda membangun komunikasi API-ke-API, atau Anda mengimplementasikan sistem autentikasi pihak ketiga.
Dalam praktiknya: Kebanyakan aplikasi seharusnya menggunakan session. Argumen "JWT lebih skalabel" hanya berlaku jika Anda benar-benar memiliki masalah penskalaan yang penyimpanan session tidak bisa selesaikan — dan Redis menangani jutaan pencarian session per detik. Saya sudah melihat terlalu banyak proyek memilih JWT karena terdengar lebih modern, kemudian membangun blocklist dan sistem refresh token yang lebih kompleks daripada session.
Mendalami JWT#
Bahkan jika Anda memilih autentikasi berbasis session, Anda akan menemui JWT melalui OAuth, OIDC, dan integrasi pihak ketiga. Memahami internal-nya tidak bisa ditawar.
Anatomi JWT#
JWT memiliki tiga bagian yang dipisahkan oleh titik: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — mendeklarasikan algoritma dan tipe token:
{
"alg": "RS256",
"typ": "JWT"
}Payload — berisi klaim. Klaim standar memiliki nama pendek:
{
"sub": "user_123", // Subject (tentang siapa ini)
"iss": "https://auth.example.com", // Issuer (siapa yang membuat)
"aud": "https://api.example.com", // Audience (siapa yang harus menerima)
"iat": 1709312000, // Issued At (timestamp Unix)
"exp": 1709312900, // Expiration (timestamp Unix)
"role": "admin" // Klaim kustom
}Signature — membuktikan token tidak dirusak. Dibuat dengan menandatangani header dan payload yang di-encode dengan kunci rahasia.
RS256 vs HS256: Ini Benar-Benar Penting#
HS256 (HMAC-SHA256) — simetris. Secret yang sama menandatangani dan memverifikasi. Sederhana, tetapi setiap layanan yang perlu memverifikasi token harus memiliki secret-nya. Jika salah satu dikompromikan, penyerang bisa memalsukan token.
RS256 (RSA-SHA256) — asimetris. Kunci privat menandatangani, kunci publik memverifikasi. Hanya server autentikasi yang membutuhkan kunci privat. Layanan mana pun bisa memverifikasi dengan kunci publik. Jika layanan verifikasi dikompromikan, penyerang bisa membaca token tetapi tidak bisa memalsukannya.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — gunakan ini ketika beberapa layanan memverifikasi token
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // KRITIS: selalu batasi algoritma
});
return payload;
}Aturan: Gunakan RS256 setiap kali token melintas batas layanan. Gunakan HS256 hanya ketika layanan yang sama menandatangani dan memverifikasi.
Serangan alg: none#
Ini adalah kerentanan JWT paling terkenal, dan sangat memalukan sederhananya. Beberapa library JWT dulu:
- Membaca field
algdari header - Menggunakan algoritma apa pun yang ditentukan
- Jika
alg: "none", melewati verifikasi tanda tangan sepenuhnya
Penyerang bisa mengambil JWT yang valid, mengubah payload (misalnya, mengatur "role": "admin"), mengatur alg ke "none", menghapus tanda tangan, dan mengirimkannya. Server akan menerimanya.
// RENTAN — jangan pernah lakukan ini
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Tidak perlu tanda tangan" — BENCANA
return JSON.parse(atob(payloadB64));
}
// ... verifikasi tanda tangan
}Perbaikannya sederhana: selalu tentukan algoritma yang diharapkan secara eksplisit. Jangan pernah biarkan token memberitahu Anda cara memverifikasinya.
// AMAN — algoritma di-hardcode, bukan dibaca dari token
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Hanya terima RS256 — abaikan header
});Library modern seperti jose menangani ini dengan benar secara default, tetapi Anda tetap harus secara eksplisit mengoper opsi algorithms sebagai pertahanan berlapis.
Serangan Kebingungan Algoritma#
Berkaitan dengan yang di atas: jika server dikonfigurasi untuk menerima RS256, penyerang mungkin:
- Mendapatkan kunci publik server (memang publik)
- Membuat token dengan
alg: "HS256" - Menandatanganinya menggunakan kunci publik sebagai secret HMAC
Jika server membaca header alg dan beralih ke verifikasi HS256, kunci publik (yang diketahui semua orang) menjadi shared secret. Tanda tangannya valid. Penyerang telah memalsukan token.
Sekali lagi, perbaikannya sama: jangan pernah percaya algoritma dari header token. Selalu hardcode-kan.
Rotasi Refresh Token#
Jika Anda menggunakan JWT, Anda membutuhkan strategi refresh token. Mengirim access token berumur panjang adalah mengundang masalah — jika dicuri, penyerang memiliki akses selama seluruh masa hidup token.
Polanya:
- Access token: berumur pendek (15 menit). Digunakan untuk request API.
- Refresh token: berumur panjang (30 hari). Digunakan hanya untuk mendapatkan access token baru.
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Mengelompokkan token terkait
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// Simpan catatan refresh token
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}Rotasi di Setiap Penggunaan#
Setiap kali klien menggunakan refresh token untuk mendapatkan access token baru, Anda menerbitkan refresh token baru dan membatalkan yang lama:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token tidak ada — kemungkinan pencurian
return null;
}
if (record.expiresAt < new Date()) {
// Token kedaluwarsa
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// TOKEN INI SUDAH DIGUNAKAN.
// Seseorang me-replay-nya — entah pengguna sah
// atau penyerang. Bagaimanapun, matikan seluruh keluarga.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Penggunaan ulang refresh token terdeteksi untuk user ${record.userId}, family ${record.familyId}. Semua token dalam keluarga dibatalkan.`
);
return null;
}
// Tandai token saat ini sebagai terpakai (jangan hapus — kita butuh untuk deteksi penggunaan ulang)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Terbitkan pasangan baru dengan family ID yang sama
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Keluarga yang sama
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Mengapa Pembatalan Keluarga Penting#
Pertimbangkan skenario ini:
- Pengguna login, mendapat refresh token A
- Penyerang mencuri refresh token A
- Penyerang menggunakan A untuk mendapatkan pasangan baru (access token + refresh token B)
- Pengguna mencoba menggunakan A (yang masih mereka miliki) untuk me-refresh
Tanpa deteksi penggunaan ulang, pengguna hanya mendapat error. Penyerang melanjutkan dengan token B. Pengguna login lagi, tidak pernah tahu akun mereka dikompromikan.
Dengan deteksi penggunaan ulang dan pembatalan keluarga: ketika pengguna mencoba menggunakan token A yang sudah terpakai, sistem mendeteksi penggunaan ulang, membatalkan setiap token dalam keluarga (termasuk B), dan memaksa pengguna dan penyerang untuk re-autentikasi. Pengguna mendapat prompt "silakan login lagi" dan mungkin menyadari ada yang salah.
Ini adalah pendekatan yang digunakan oleh Auth0, Okta, dan Auth.js. Tidak sempurna — jika penyerang menggunakan token sebelum pengguna sah melakukannya, pengguna sah yang memicu peringatan penggunaan ulang. Tetapi ini adalah yang terbaik yang bisa kita lakukan dengan bearer token.
OAuth 2.0 & OIDC#
OAuth 2.0 dan OpenID Connect adalah protokol di balik "Masuk dengan Google/GitHub/Apple." Memahaminya sangat penting bahkan jika Anda menggunakan library, karena ketika ada masalah — dan pasti akan — Anda perlu tahu apa yang terjadi di level protokol.
Perbedaan Kunci#
OAuth 2.0 adalah protokol otorisasi. Ia menjawab: "Bisakah aplikasi ini mengakses data pengguna ini?" Hasilnya adalah access token yang memberikan izin spesifik (scope).
OpenID Connect (OIDC) adalah lapisan autentikasi yang dibangun di atas OAuth 2.0. Ia menjawab: "Siapa pengguna ini?" Hasilnya adalah ID token (sebuah JWT) yang berisi informasi identitas pengguna.
Ketika Anda "Masuk dengan Google," Anda menggunakan OIDC. Google memberitahu aplikasi Anda siapa pengguna tersebut (autentikasi). Anda mungkin juga meminta scope OAuth untuk mengakses kalender atau drive mereka (otorisasi).
Authorization Code Flow dengan PKCE#
Ini adalah alur yang harus Anda gunakan untuk aplikasi web. PKCE (Proof Key for Code Exchange) awalnya dirancang untuk aplikasi mobile tetapi sekarang direkomendasikan untuk semua klien, termasuk aplikasi sisi server.
import { randomBytes, createHash } from "crypto";
// Langkah 1: Buat nilai PKCE dan redirect pengguna
function initiateOAuthFlow() {
// Code verifier: string acak 43-128 karakter
const codeVerifier = randomBytes(32)
.toString("base64url")
.slice(0, 43);
// Code challenge: hash SHA256 dari verifier, di-encode base64url
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// State: nilai acak untuk perlindungan CSRF
const state = randomBytes(16).toString("hex");
// Simpan keduanya di session (sisi server!) sebelum me-redirect
// JANGAN PERNAH taruh code_verifier di cookie atau parameter URL
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// Langkah 2: Tangani callback
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Periksa error dari provider
if (error) {
throw new Error(`Error OAuth: ${error}`);
}
// Verifikasi state cocok (perlindungan CSRF)
if (state !== session.oauthState) {
throw new Error("State tidak cocok — kemungkinan serangan CSRF");
}
// Tukar authorization code dengan token
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: membuktikan kita yang memulai alur ini
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — untuk panggilan API ke Google
// tokens.id_token — JWT dengan identitas pengguna (OIDC)
// tokens.refresh_token — untuk mendapatkan access token baru
// Langkah 3: Verifikasi ID token dan ekstrak info pengguna
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Tiga Endpoint#
Setiap provider OAuth/OIDC mengekspos ini:
- Authorization endpoint — di mana Anda me-redirect pengguna untuk login dan memberikan izin. Mengembalikan authorization code.
- Token endpoint — di mana server Anda menukar authorization code dengan access/refresh/ID token. Ini adalah panggilan server-ke-server.
- UserInfo endpoint — di mana Anda bisa mengambil data profil pengguna tambahan menggunakan access token. Dengan OIDC, sebagian besar sudah ada di ID token.
Parameter State#
Parameter state mencegah serangan CSRF pada callback OAuth. Tanpanya:
- Penyerang memulai alur OAuth di mesin mereka sendiri, mendapat authorization code
- Penyerang membuat URL:
https://yourapp.com/callback?code=KODE_PENYERANG - Penyerang menipu korban untuk mengkliknya (tautan email, gambar tersembunyi)
- Aplikasi Anda menukar kode penyerang dan menghubungkan akun Google penyerang ke session korban
Dengan state: aplikasi Anda menghasilkan nilai acak, menyimpannya di session, dan menyertakannya di URL otorisasi. Ketika callback datang, Anda memverifikasi state cocok. Penyerang tidak bisa memalsukannya karena mereka tidak memiliki akses ke session korban.
Auth.js (NextAuth) dengan Next.js App Router#
Auth.js adalah yang pertama saya ambil di kebanyakan proyek Next.js. Ia menangani tarian OAuth, manajemen session, persistensi basis data, dan perlindungan CSRF. Berikut setup siap produksi.
Konfigurasi Dasar#
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// Gunakan session basis data (bukan JWT) untuk keamanan yang lebih baik
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 hari
updateAge: 24 * 60 * 60, // Perpanjang session setiap 24 jam
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Minta scope spesifik
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Dapatkan refresh token
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Login email/password (gunakan dengan hati-hati)
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// Kontrol siapa yang bisa masuk
async signIn({ user, account }) {
// Blokir sign-in untuk pengguna yang diblokir
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Tambahkan field kustom ke session
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Ambil role dari basis data
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Perlindungan Middleware#
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login")
|| req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
|| req.nextUrl.pathname.startsWith("/settings")
|| req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// Redirect pengguna yang sudah login dari halaman autentikasi
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Redirect pengguna yang belum terautentikasi ke login
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Periksa role admin
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/admin/:path*",
"/login",
"/register",
],
};Menggunakan Session di Server Component#
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Selamat datang, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
</div>
);
}Menggunakan Session di Client Component#
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Memuat...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Masuk</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "Pengguna"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkey (WebAuthn)#
Passkey adalah peningkatan autentikasi paling signifikan dalam beberapa tahun terakhir. Mereka tahan phishing, tahan replay, dan menghilangkan seluruh kategori kerentanan terkait password. Jika Anda memulai proyek baru di 2026, Anda harus mendukung passkey.
Cara Kerja Passkey#
Passkey menggunakan kriptografi kunci publik, didukung oleh biometrik atau PIN perangkat:
- Registrasi: Browser menghasilkan pasangan kunci. Kunci privat tetap di perangkat (di secure enclave, dilindungi oleh biometrik). Kunci publik dikirim ke server Anda.
- Autentikasi: Server mengirim tantangan (byte acak). Perangkat menandatangani tantangan dengan kunci privat (setelah verifikasi biometrik). Server memverifikasi tanda tangan dengan kunci publik yang tersimpan.
Tidak ada shared secret yang melintasi jaringan. Tidak ada yang bisa di-phishing, tidak ada yang bisa bocor, tidak ada yang bisa di-stuff.
Mengapa Passkey Tahan Phishing#
Ketika passkey dibuat, ia terikat ke origin (misalnya, https://example.com). Browser hanya akan menggunakan passkey pada origin yang tepat di mana ia dibuat. Jika penyerang membuat situs tiruan di https://exarnple.com, passkey tidak akan ditawarkan. Ini ditegakkan oleh browser, bukan oleh kewaspadaan pengguna.
Ini secara fundamental berbeda dari password, di mana pengguna secara rutin memasukkan kredensial mereka di situs phishing karena halaman terlihat benar.
Implementasi dengan SimpleWebAuthn#
SimpleWebAuthn adalah library yang saya rekomendasikan. Ia menangani protokol WebAuthn dengan benar dan memiliki tipe TypeScript yang baik.
// Sisi server: Registrasi
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
async function startRegistration(userId: string, userEmail: string) {
// Dapatkan passkey pengguna yang sudah ada untuk mengecualikannya
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // Kita tidak perlu attestation untuk kebanyakan aplikasi
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Simpan tantangan sementara — kita butuh untuk verifikasi
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300 // Kedaluwarsa 5 menit
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Tantangan kedaluwarsa atau tidak ditemukan");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Verifikasi registrasi gagal: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Verifikasi registrasi gagal");
}
const { credential } = verification.registrationInfo;
// Simpan credential di basis data
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Bersihkan
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Sisi server: Autentikasi
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Jika kita tahu penggunanya (misalnya, mereka memasukkan email), batasi ke passkey mereka
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// Simpan tantangan untuk verifikasi
const challengeKey = userId
? `webauthn:auth:${userId}`
: `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(
response: any,
expectedChallenge: string,
userId: string
) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential tidak ditemukan");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Verifikasi autentikasi gagal");
}
// PENTING: Perbarui counter untuk mencegah serangan replay
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Sisi klien: Registrasi
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Dapatkan opsi dari server Anda
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Ini memicu UI passkey browser (prompt biometrik)
const credential = await webAuthnRegister(options);
// Kirim credential ke server Anda untuk verifikasi
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey berhasil didaftarkan!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("Pengguna membatalkan pendaftaran passkey");
}
}
}Attestation vs Assertion#
Dua istilah yang akan Anda temui:
- Attestation (registrasi): Proses membuat credential baru. Authenticator "membuktikan" identitas dan kemampuannya. Untuk kebanyakan aplikasi, Anda tidak perlu memverifikasi attestation — atur
attestationType: "none". - Assertion (autentikasi): Proses menggunakan credential yang ada untuk menandatangani tantangan. Authenticator "menyatakan" bahwa pengguna adalah siapa yang mereka klaim.
Implementasi MFA#
Bahkan dengan passkey, Anda akan menemui skenario di mana MFA via TOTP diperlukan — passkey sebagai faktor kedua di samping password, atau mendukung pengguna yang perangkatnya tidak mendukung passkey.
TOTP (Time-Based One-Time Passwords)#
TOTP adalah protokol di balik Google Authenticator, Authy, dan 1Password. Cara kerjanya:
- Server menghasilkan secret acak (di-encode base32)
- Pengguna memindai kode QR yang berisi secret
- Server dan aplikasi authenticator menghitung kode 6 digit yang sama dari secret dan waktu saat ini
- Kode berubah setiap 30 detik
import { createHmac, randomBytes } from "crypto";
// Hasilkan secret TOTP untuk pengguna
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// Hasilkan URI TOTP untuk kode QR
function generateTOTPUri(
secret: string,
userEmail: string,
issuer: string = "akousa.net"
): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// Verifikasi kode TOTP
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Periksa langkah waktu saat ini dan yang berdekatan (toleransi perbedaan jam)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Perbandingan waktu konstan untuk mencegah serangan timing
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Konversi langkah waktu ke buffer big-endian 8 byte
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamic truncation
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest()
.equals(createHmac("sha256", "key").update(bufB).digest());
}Kode Cadangan#
Pengguna bisa kehilangan ponsel mereka. Selalu hasilkan kode cadangan saat pengaturan MFA:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString("hex").toUpperCase() // Kode hex 8 karakter
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Hash kode sebelum menyimpan — perlakukan seperti password
const hashedCodes = codes.map((code) =>
createHash("sha256").update(code).digest("hex")
);
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Kembalikan kode plain SEKALI untuk disimpan pengguna
// Setelah ini, kita hanya memiliki hash-nya
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256")
.update(code.toUpperCase().replace(/\s/g, ""))
.digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// Tandai sebagai terpakai — setiap kode cadangan hanya berfungsi sekali
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Alur Pemulihan#
Pemulihan MFA adalah bagian yang kebanyakan tutorial lewatkan dan kebanyakan aplikasi nyata gagal menangani. Berikut yang saya implementasikan:
- Primer: Kode TOTP dari aplikasi authenticator
- Sekunder: Salah satu dari 10 kode cadangan
- Pilihan terakhir: Pemulihan berbasis email dengan periode tunggu 24 jam dan notifikasi ke channel terverifikasi lainnya
Periode tunggu itu kritis. Jika penyerang telah mengkompromikan email pengguna, Anda tidak ingin membiarkan mereka menonaktifkan MFA secara instan. Penundaan 24 jam memberikan waktu bagi pengguna sah untuk memperhatikan email dan mengintervensi.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Jangan ungkapkan apakah akun ada
return { message: "Jika email tersebut ada, kami telah mengirim instruksi pemulihan." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 jam
status: "pending",
},
});
// Kirim email dengan tautan pemulihan
await sendEmail(email, {
subject: "Permintaan Pemulihan Akun",
body: `
Permintaan dibuat untuk menonaktifkan MFA di akun Anda.
Jika ini Anda, klik tautan di bawah setelah 24 jam: ...
Jika ini BUKAN Anda, segera ubah password Anda.
`,
});
return { message: "Jika email tersebut ada, kami telah mengirim instruksi pemulihan." };
}Pola Otorisasi#
Autentikasi memberitahu Anda siapa seseorang. Otorisasi memberitahu Anda apa yang boleh mereka lakukan. Melakukan ini dengan salah adalah cara Anda berakhir di berita.
RBAC vs ABAC#
RBAC (Role-Based Access Control): Pengguna memiliki role, role memiliki izin. Sederhana, mudah dipahami, berfungsi untuk kebanyakan aplikasi.
// RBAC — pemeriksaan role yang langsung
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile", "update:own_profile",
"read:posts", "create:posts", "update:posts", "delete:posts",
"read:users", "update:users",
],
super_admin: ["*"], // Hati-hati dengan wildcard
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Penggunaan di route API
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Tidak terotorisasi" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Dilarang" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): Izin bergantung pada atribut pengguna, resource, dan konteks. Lebih fleksibel tetapi lebih kompleks.
// ABAC — ketika RBAC tidak cukup
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// Pengguna selalu bisa membaca resource milik mereka sendiri
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admin bisa membaca resource apa pun di departemen mereka
if (
action === "read" &&
user.role === "admin" &&
user.department === resource.department
) {
return true;
}
// Resource rahasia memerlukan MFA dan clearance minimum
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Aksi destruktif diblokir di luar jam kerja
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Default tolak
}Aturan "Periksa di Batas"#
Ini adalah prinsip otorisasi tunggal yang paling penting: periksa izin di setiap batas kepercayaan, bukan hanya di level UI.
// BURUK — hanya memeriksa di komponen
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Ini menyembunyikan tombol, tetapi tidak mencegah penghapusan
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Hapus</button>;
}
// JUGA BURUK — memeriksa di server action tetapi bukan route API
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Dilarang");
await db.post.delete({ where: { id: postId } });
}
// Penyerang masih bisa memanggil POST /api/posts/123 secara langsung
// BAIK — periksa di setiap batas
// 1. Sembunyikan tombol di UI (UX, bukan keamanan)
// 2. Periksa di server action (pertahanan berlapis)
// 3. Periksa di route API (batas keamanan yang sebenarnya)
// 4. Opsional, periksa di middleware (untuk perlindungan level route)Pemeriksaan UI adalah untuk pengalaman pengguna. Pemeriksaan server adalah untuk keamanan. Jangan pernah mengandalkan hanya salah satunya.
Pemeriksaan Izin di Middleware Next.js#
Middleware berjalan sebelum setiap request yang cocok. Ini tempat yang baik untuk kontrol akses kasar:
// "Apakah pengguna ini diizinkan mengakses bagian ini sama sekali?"
// Pemeriksaan detail ("Bisakah pengguna ini mengedit POST ini?") ada di route handler
// karena middleware tidak memiliki akses mudah ke body request atau parameter route.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Kontrol akses level route
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});Kerentanan Umum#
Ini adalah serangan yang paling sering saya lihat di codebase nyata. Memahaminya sangat penting.
Session Fixation#
Serangannya: Penyerang membuat session yang valid di situs Anda, kemudian menipu korban untuk menggunakan session ID tersebut (misalnya, melalui parameter URL atau dengan mengatur cookie melalui subdomain). Ketika korban login, session penyerang sekarang memiliki pengguna yang terautentikasi.
Perbaikannya: Selalu regenerasi session ID setelah autentikasi berhasil. Jangan pernah biarkan session ID pra-autentikasi berlanjut ke session pasca-autentikasi.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Kredensial tidak valid");
// KRITIS: Hapus session lama dan buat yang baru
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Buat session baru sepenuhnya dengan ID baru
const newSessionId = await createSession(user.id, request);
return newSessionId;
}CSRF (Cross-Site Request Forgery)#
Serangannya: Pengguna sudah login ke situs Anda. Mereka mengunjungi halaman jahat yang membuat request ke situs Anda. Karena cookie dikirim secara otomatis, request tersebut terautentikasi.
Perbaikan modern: Cookie SameSite. Mengatur SameSite: Lax (default di kebanyakan browser sekarang) mencegah cookie dikirim pada request POST lintas-origin, yang mencakup kebanyakan skenario CSRF.
// SameSite=Lax mencakup kebanyakan skenario CSRF:
// - Memblokir cookie pada POST, PUT, DELETE lintas-origin
// - Mengizinkan cookie pada GET lintas-origin (navigasi top-level)
// Ini tidak masalah karena request GET seharusnya tidak memiliki efek samping
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Ini adalah perlindungan CSRF Anda
maxAge: 86400,
path: "/",
});Untuk API yang menerima JSON, Anda mendapat perlindungan tambahan secara gratis: header Content-Type: application/json tidak bisa diatur oleh form HTML, dan CORS mencegah JavaScript di origin lain membuat request dengan header kustom.
Jika Anda membutuhkan jaminan lebih kuat (misalnya, Anda menerima form submission), gunakan pola double-submit cookie atau synchronizer token. Auth.js menangani ini untuk Anda.
Open Redirect di OAuth#
Serangannya: Penyerang membuat URL callback OAuth yang me-redirect ke situs mereka setelah autentikasi: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
Jika handler callback Anda membabi buta me-redirect ke parameter redirect_to, pengguna berakhir di situs penyerang, berpotensi dengan token di URL.
// RENTAN
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... autentikasi pengguna ...
return Response.redirect(redirectTo); // Bisa jadi https://evil.com!
}
// AMAN
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Validasi URL redirect
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... autentikasi pengguna ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Hanya izinkan redirect ke origin yang sama
if (url.origin !== base.origin) {
return "/";
}
// Hanya izinkan redirect path (bukan URI javascript: atau data:)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Kebocoran Token via Referrer#
Jika Anda menaruh token di URL (jangan), mereka akan bocor melalui header Referer ketika pengguna mengklik tautan. Ini telah menyebabkan kebocoran nyata, termasuk di GitHub.
Aturan:
- Jangan pernah menaruh token di parameter query URL untuk autentikasi
- Atur
Referrer-Policy: strict-origin-when-cross-origin(atau lebih ketat) - Jika Anda harus menaruh token di URL (misalnya, tautan verifikasi email), buat sekali pakai dan berumur pendek
// Di middleware atau layout Next.js Anda
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
Serangan yang kurang dikenal: beberapa library JWT mendukung header jwk atau jku yang memberitahu verifier di mana menemukan kunci publik. Penyerang bisa:
- Menghasilkan pasangan kunci mereka sendiri
- Membuat JWT dengan payload mereka dan menandatanganinya dengan kunci privat mereka
- Mengatur header
jwkuntuk menunjuk ke kunci publik mereka
Jika library Anda membabi buta mengambil dan menggunakan kunci dari header jwk, tanda tangannya terverifikasi. Penyerang telah memalsukan token. Perbaikannya: jangan pernah biarkan token menentukan kunci verifikasinya sendiri. Selalu gunakan kunci dari konfigurasi Anda sendiri.
Stack Autentikasi Saya di 2026#
Setelah bertahun-tahun membangun sistem autentikasi, inilah yang benar-benar saya gunakan saat ini.
Untuk Kebanyakan Proyek: Auth.js + PostgreSQL + Passkey#
Ini adalah stack default saya untuk proyek baru:
- Auth.js (v5) untuk pekerjaan berat: provider OAuth, manajemen session, CSRF, adapter basis data
- PostgreSQL dengan adapter Prisma untuk penyimpanan session dan akun
- Passkey via SimpleWebAuthn sebagai metode login utama untuk pengguna baru
- Email/password sebagai fallback untuk pengguna yang tidak bisa menggunakan passkey
- TOTP MFA sebagai faktor kedua untuk login berbasis password
Strategi session-nya didukung basis data (bukan JWT), yang memberikan saya pencabutan instan dan manajemen session yang sederhana.
// Ini adalah auth.ts tipikal saya untuk proyek baru
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 memiliki dukungan passkey bawaan
// Ini menggunakan SimpleWebAuthn di balik layar
}),
],
experimental: {
enableWebAuthn: true,
},
});Kapan Menggunakan Clerk atau Auth0 Sebagai Gantinya#
Saya memilih provider autentikasi terkelola ketika:
- Proyek membutuhkan SSO enterprise (SAML, SCIM). Mengimplementasikan SAML dengan benar adalah proyek berbulan-bulan. Clerk melakukannya langsung.
- Tim tidak memiliki keahlian keamanan. Jika tidak ada di tim yang bisa menjelaskan PKCE, mereka tidak seharusnya membangun autentikasi dari nol.
- Time to market lebih penting daripada biaya. Auth.js gratis tetapi butuh berhari-hari untuk diatur dengan benar. Clerk butuh satu sore.
- Anda membutuhkan jaminan kepatuhan (SOC 2, HIPAA). Provider terkelola menangani sertifikasi kepatuhan.
Trade-off dari provider terkelola:
- Biaya: Clerk mengenakan biaya per pengguna aktif bulanan. Pada skala besar, ini bertambah.
- Vendor lock-in: Migrasi dari Clerk atau Auth0 itu menyakitkan. Tabel pengguna Anda ada di server mereka.
- Batasan kustomisasi: Jika alur autentikasi Anda tidak biasa, Anda akan bertarung dengan opini provider.
- Latensi: Setiap pemeriksaan autentikasi pergi ke API pihak ketiga. Dengan session basis data, itu query lokal.
Apa yang Saya Hindari#
- Membuat kripto sendiri. Saya menggunakan
joseuntuk JWT,@simplewebauthn/serveruntuk passkey,bcryptatauargon2untuk password. Tidak pernah buatan tangan. - Menyimpan password di SHA256. Gunakan bcrypt (cost factor 12+) atau argon2id. SHA256 terlalu cepat — penyerang bisa mencoba miliaran hash per detik dengan GPU.
- Access token berumur panjang. Maksimal 15 menit. Gunakan rotasi refresh token untuk session yang lebih lama.
- Secret simetris untuk verifikasi lintas-layanan. Jika beberapa layanan perlu memverifikasi token, gunakan RS256 dengan pasangan kunci publik/privat.
- Session ID kustom dengan entropi tidak cukup. Gunakan minimal
crypto.randomBytes(32). UUID v4 bisa diterima tetapi entropi-nya lebih rendah dari byte acak mentah.
Hashing Password: Cara yang Benar#
Karena kita membahasnya — berikut cara meng-hash password dengan benar di 2026:
import { hash, verify } from "@node-rs/argon2";
// Argon2id adalah algoritma yang direkomendasikan
// Ini adalah default yang masuk akal untuk aplikasi web
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterasi
parallelism: 4, // 4 thread
});
}
async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Mengapa argon2id dibanding bcrypt? Argon2id bersifat memory-hard, yang berarti menyerangnya membutuhkan tidak hanya kekuatan CPU tetapi juga RAM dalam jumlah besar. Ini membuat serangan GPU dan ASIC jauh lebih mahal. Bcrypt masih bagus — tidak rusak — tetapi argon2id adalah pilihan yang lebih baik untuk proyek baru.
Checklist Keamanan#
Sebelum meluncurkan sistem autentikasi apa pun, verifikasi:
- Password di-hash dengan argon2id atau bcrypt (cost 12+)
- Session diregenerasi setelah login (mencegah session fixation)
- Cookie adalah
HttpOnly,Secure,SameSite=LaxatauStrict - JWT menentukan algoritma secara eksplisit (jangan pernah percaya header
alg) - Access token kedaluwarsa dalam 15 menit atau kurang
- Rotasi refresh token diimplementasikan dengan deteksi penggunaan ulang
- Parameter state OAuth diverifikasi (perlindungan CSRF)
- URL redirect divalidasi terhadap allowlist
- Rate limiting diterapkan pada endpoint login, registrasi, dan reset password
- Percobaan login gagal dicatat dengan IP tetapi bukan password
- Penguncian akun setelah N percobaan gagal (dengan penundaan progresif, bukan kunci permanen)
- Token reset password sekali pakai dan kedaluwarsa dalam 1 jam
- Kode cadangan MFA di-hash seperti password
- CORS dikonfigurasi untuk hanya mengizinkan origin yang dikenal
- Header
Referrer-Policydisetel - Tidak ada data sensitif di payload JWT (bisa dibaca oleh siapa pun)
- Counter WebAuthn diverifikasi dan diperbarui (mencegah kloning credential)
Daftar ini tidak lengkap, tetapi mencakup kerentanan yang paling sering saya temui di sistem produksi.
Penutup#
Autentikasi adalah salah satu domain di mana lanskap terus berkembang, tetapi fundamentalnya tetap sama: verifikasi identitas, terbitkan credential seminimal mungkin, periksa izin di setiap batas, dan asumsikan terjadi pelanggaran.
Pergeseran terbesar di 2026 adalah passkey menjadi mainstream. Dukungan browser sudah universal, dukungan platform (iCloud Keychain, Google Password Manager) membuat UX mulus, dan properti keamanannya benar-benar lebih unggul dari apa pun yang pernah kita miliki sebelumnya. Jika Anda membangun aplikasi baru, jadikan passkey metode login utama Anda dan perlakukan password sebagai fallback.
Pergeseran terbesar kedua adalah bahwa membangun autentikasi sendiri semakin sulit dibenarkan. Auth.js v5, Clerk, dan solusi serupa menangani bagian-bagian sulit dengan benar. Satu-satunya alasan untuk kustomisasi adalah ketika persyaratan Anda benar-benar tidak cocok dengan solusi yang ada — dan itu lebih jarang dari yang dipikirkan kebanyakan developer.
Apa pun yang Anda pilih, uji autentikasi Anda seperti yang dilakukan penyerang. Coba replay token, palsukan tanda tangan, akses route yang seharusnya tidak Anda akses, dan manipulasi URL redirect. Bug yang Anda temukan sebelum peluncuran adalah yang tidak masuk berita.