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.
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.
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:
Kekurangannya juga nyata:
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://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}Keuntungannya:
Kekurangannya — dan inilah yang sering dilewatkan orang:
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.
Bahkan jika Anda memilih autentikasi berbasis session, Anda akan menemui JWT melalui OAuth, OIDC, dan integrasi pihak ketiga. Memahami internal-nya tidak bisa ditawar.
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.
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.
alg: noneIni adalah kerentanan JWT paling terkenal, dan sangat memalukan sederhananya. Beberapa library JWT dulu:
alg dari headeralg: "none", melewati verifikasi tanda tangan sepenuhnyaPenyerang 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.
Berkaitan dengan yang di atas: jika server dikonfigurasi untuk menerima RS256, penyerang mungkin:
alg: "HS256"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.
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:
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 };
}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 };
}Pertimbangkan skenario ini:
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 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.
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).
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,
};
}Setiap provider OAuth/OIDC mengekspos ini:
Parameter state mencegah serangan CSRF pada callback OAuth. Tanpanya:
https://yourapp.com/callback?code=KODE_PENYERANGDengan 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 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.
// 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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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"],
};// 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>
);
}"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 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.
Passkey menggunakan kriptografi kunci publik, didukung oleh biometrik atau PIN perangkat:
Tidak ada shared secret yang melintasi jaringan. Tidak ada yang bisa di-phishing, tidak ada yang bisa bocor, tidak ada yang bisa di-stuff.
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.
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 = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
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");
}
}
}Dua istilah yang akan Anda temui:
attestationType: "none".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 adalah protokol di balik Google Authenticator, Authy, dan 1Password. Cara kerjanya:
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 = "example.com"): 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());
}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;
}Pemulihan MFA adalah bagian yang kebanyakan tutorial lewatkan dan kebanyakan aplikasi nyata gagal menangani. Berikut yang saya implementasikan:
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." };
}Autentikasi memberitahu Anda siapa seseorang. Otorisasi memberitahu Anda apa yang boleh mereka lakukan. Melakukan ini dengan salah adalah cara Anda berakhir di berita.
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
}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.
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();
});Ini adalah serangan yang paling sering saya lihat di codebase nyata. Memahaminya sangat penting.
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;
}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.
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 "/";
}
}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:
Referrer-Policy: strict-origin-when-cross-origin (atau lebih ketat)// Di middleware atau layout Next.js Anda
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Serangan yang kurang dikenal: beberapa library JWT mendukung header jwk atau jku yang memberitahu verifier di mana menemukan kunci publik. Penyerang bisa:
jwk untuk menunjuk ke kunci publik merekaJika 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.
Setelah bertahun-tahun membangun sistem autentikasi, inilah yang benar-benar saya gunakan saat ini.
Ini adalah stack default saya untuk proyek baru:
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,
},
});Saya memilih provider autentikasi terkelola ketika:
Trade-off dari provider terkelola:
jose untuk JWT, @simplewebauthn/server untuk passkey, bcrypt atau argon2 untuk password. Tidak pernah buatan tangan.crypto.randomBytes(32). UUID v4 bisa diterima tetapi entropi-nya lebih rendah dari byte acak mentah.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.
Sebelum meluncurkan sistem autentikasi apa pun, verifikasi:
HttpOnly, Secure, SameSite=Lax atau Strictalg)Referrer-Policy disetelDaftar ini tidak lengkap, tetapi mencakup kerentanan yang paling sering saya temui di sistem produksi.
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.