Autentikasi Aman dengan Claude Code: Session Next.js, Batas JWT, dan OAuth
Bangun auth aman dengan Claude Code: session Next.js, JWT, OAuth, CSRF, RBAC, audit log, dan test.
Autentikasi bukan sekadar form login. Sebelum rilis, kamu perlu memikirkan penyimpanan password, session cookie, JWT, OAuth callback, CSRF, reset password, RBAC, secrets, audit log, dan test. Jika Claude Code hanya diminta “tambahkan auth”, hasilnya bisa terlihat berjalan, tetapi desain production masih menyimpan risiko.
Shortcut berbahaya yang paling sering muncul adalah menyimpan JWT panjang di browser localStorage. Jika ada XSS, yaitu JavaScript berbahaya yang berjalan di halaman, token itu bisa dibaca dan dipakai ulang. Untuk web app biasa, default yang lebih mudah diaudit adalah server-side session: server menyimpan status login, browser hanya menerima session ID acak lewat cookie HttpOnly.
Artikel ini memakai Next.js App Router dan menyediakan demo yang bisa disalin: validasi Zod, password hashing bcrypt, signed opaque session cookie, middleware guard, CSRF dan Origin check, RBAC, audit log, serta Vitest test. JWT dibahas sebagai batas API atau mobile yang singkat. OAuth dipakai sebagai batas identity provider, bukan pengganti authorization lokal.
Gunakan referensi resmi sebagai sumber utama: Next.js Authentication guide, Next.js cookies API, OWASP Authentication Cheat Sheet, OWASP Password Storage Cheat Sheet, OWASP Forgot Password Cheat Sheet, MDN Secure cookie configuration, Auth.js, dan Claude Code docs. Untuk konteks tambahan, baca cookie management, RBAC, dan Zod validation.
Pisahkan Session, JWT, dan OAuth
Session menjawab “apakah browser ini masih login?” JWT menjawab “apakah client ini bisa membuktikan klaim bertanda tangan dalam waktu singkat?” OAuth/OIDC menjawab “apakah provider tepercaya sudah mengautentikasi user ini?” Jika tiga hal ini dicampur, review menjadi sulit.
| Mekanisme | Cocok untuk | Kekuatan | Risiko |
|---|---|---|---|
| Server-side session | Dashboard SaaS, member area, admin | Revocation dan audit lebih mudah | Perlu Redis, Postgres, DynamoDB, atau store lain |
| JWT | Mobile API, service call singkat, API eksternal | Verifikasi signature tanpa DB lookup | Revocation sulit; hindari penyimpanan panjang di browser |
| OAuth / OIDC | Google, GitHub, corporate SSO | Password user tidak disimpan sendiri | Login provider tidak mengganti permission lokal |
Tiga use case sering muncul. Dashboard SaaS memakai HttpOnly session cookie dan meminta reauthentication sebelum perubahan billing atau email. Situs konten berbayar memisahkan free reader, buyer, editor, dan admin dengan RBAC serta audit log. Tool internal B2B boleh memakai Google Workspace atau Entra ID untuk login, tetapi tenant boundary, role, expiry, dan revocation tetap dicek aplikasi.
Prompt Aman untuk Claude Code
Berikan kontrak keamanan sebelum meminta kode.
Implementasikan authentication untuk Next.js App Router.
Syarat:
- Browser login memakai server-side session dan HttpOnly Cookie
- JWT hanya untuk token API eksternal yang short-lived; jangan pakai localStorage
- Password di-hash dengan bcrypt atau Argon2id, tidak plaintext
- Input divalidasi dengan Zod dan tidak membocorkan apakah email ada
- Cookie menetapkan Secure, HttpOnly, SameSite, Path, dan Max-Age
- API yang mengubah state memeriksa Origin dan CSRF token
- OAuth memakai Auth.js atau library matang, bukan implementasi manual penuh
- Sertakan RBAC, password reset, audit logs, dan tests
- Akhiri dengan pitfalls dan verification commands
Prompt ini memaksa Claude Code bekerja di dalam batas yang bisa direview. Untuk autentikasi, melarang shortcut berbahaya sama pentingnya dengan menulis route handler.
Implementasi Minimal Next.js
Instal dependency.
npm install zod bcryptjs
npm install -D vitest typescript @types/node
Tambahkan secret minimal 32 karakter di .env.local. Jangan commit secret ini.
SESSION_SECRET="replace-with-at-least-32-random-characters"
File lib/auth/password.ts. OWASP merekomendasikan Argon2id sebagai pilihan modern; demo ini memakai bcryptjs agar mudah disalin.
import bcrypt from "bcryptjs";
import { z } from "zod";
export const passwordSchema = z.string().min(12).max(128);
export async function hashPassword(password: string) {
const parsed = passwordSchema.parse(password);
return bcrypt.hash(parsed, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
File lib/auth/session.ts. Map memori hanya untuk demo; production perlu Redis, PostgreSQL, DynamoDB, atau store bersama.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
export type Role = "user" | "admin";
type SessionRecord = { userId: string; role: Role; csrfToken: string; expiresAt: number };
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
export const SESSION_COOKIE_NAME =
env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
function signSessionId(sessionId: string) {
return createHmac("sha256", env.SESSION_SECRET).update(sessionId).digest("base64url");
}
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
export function createSession(userId: string, role: Role = "user") {
const sessionId = randomBytes(32).toString("base64url");
const token = `${sessionId}.${signSessionId(sessionId)}`;
const csrfToken = randomBytes(32).toString("base64url");
sessions.set(sessionId, {
userId,
role,
csrfToken,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
return { token, csrfToken };
}
export function getSession(token?: string) {
if (!token) return null;
const [sessionId, signature] = token.split(".");
if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return { id: sessionId, ...session };
}
export function destroySession(token?: string) {
const sessionId = token?.split(".")[0];
if (sessionId) sessions.delete(sessionId);
}
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) throw new Error("Bad origin");
}
export function assertCsrf(request: Request, session: { csrfToken: string }) {
const submitted = request.headers.get("x-csrf-token");
if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}
Route login app/api/login/route.ts. Di aplikasi nyata, passwordHash dan role berasal dari database.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { SESSION_COOKIE_NAME, createSession, sessionCookieOptions } from "@/lib/auth/session";
export const runtime = "nodejs";
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(12).max(128),
});
async function findUserByEmail(email: string) {
if (email !== "masa@example.com") return null;
return {
id: "user_123",
role: "admin" as const,
passwordHash: await hashPassword("correct-horse-battery-staple"),
};
}
export async function POST(request: NextRequest) {
const parsed = loginInputSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
const user = await findUserByEmail(parsed.data.email);
const passwordOk = user ? await verifyPassword(parsed.data.password, user.passwordHash) : false;
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createSession(user.id, user.role);
const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
response.cookies.set({ name: SESSION_COOKIE_NAME, value: session.token, ...sessionCookieOptions });
response.cookies.set({
name: "csrf-token",
value: session.csrfToken,
secure: sessionCookieOptions.secure,
sameSite: "lax",
path: "/",
maxAge: sessionCookieOptions.maxAge,
});
return response;
}
middleware.ts hanya guard untuk navigasi. Authorization final tetap di server route.
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE_NAME =
process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
const pathname = request.nextUrl.pathname;
if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
Test test/auth.test.ts.
import { beforeAll, describe, expect, it } from "vitest";
beforeAll(() => {
process.env.NODE_ENV = "test";
process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});
describe("auth primitives", () => {
it("hashes and verifies passwords", async () => {
const { hashPassword, verifyPassword } = await import("../lib/auth/password");
const hash = await hashPassword("correct-horse-battery-staple");
await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
});
it("creates and destroys a session", async () => {
const { createSession, destroySession, getSession } = await import("../lib/auth/session");
const session = createSession("user_123", "admin");
expect(getSession(session.token)?.role).toBe("admin");
destroySession(session.token);
expect(getSession(session.token)).toBeNull();
});
it("validates login input", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
});
});
Password Reset, OAuth, dan JWT
Reset password tidak boleh membocorkan apakah email terdaftar. Gunakan pesan yang sama, buat token acak kuat, simpan hanya hash token, beri expiry singkat, dan jadikan single-use. Setelah password berubah, sediakan opsi untuk invalidasi session lama.
OAuth sebaiknya tidak ditulis manual. Gunakan Auth.js atau library matang, hubungkan provider identity ke user lokal, lalu buat session cookie aplikasi sendiri. Provider access token bukan cookie session aplikasi.
JWT berguna untuk API, tetapi bukan jawaban untuk semua auth. Jika dipakai, tetapkan aud, iss, exp, key rotation, dan respons saat token bocor.
Pitfalls, Audit Log, dan Monetisasi
Sebelum deploy, blokir kesalahan ini: JWT panjang di localStorage, cookie tanpa Secure atau HttpOnly, password hash SHA-256 biasa, reset token plaintext, error login yang membuka apakah email ada, RBAC hanya di middleware, dan log yang mencetak secret.
Audit log berisi actor, action, result, dan waktu. Jangan simpan password, session ID, reset token, atau OAuth access token. Untuk SaaS, cek tenantId sebelum role.
Autentikasi juga menjaga revenue path: konten premium, template, link Gumroad, form B2B, dan dashboard member. Mulai dari checklist gratis untuk membakukan review Claude Code. Jika perlu resource reusable, lihat products/templates. Untuk desain auth, RBAC, audit log, dan CI test di repo nyata, gunakan Claude Code training/consultation.
Hasil Praktik
Saat Masa mencoba alur ini, nilai utamanya bukan login route saja. Session cookie, CSRF, RBAC, audit log, dan test menjadi satu unit kerja. Versi awal membuat middleware terlihat seperti security boundary, padahal hanya mengecek keberadaan cookie. Memindahkan check nyata ke server route membuat review lebih jelas dan mengurangi risiko admin page terbuka.
Ringkasan
Saat membangun auth dengan Claude Code, mulai dari batasnya. Browser memakai server-side session dan secure cookie. API memakai JWT singkat. Login eksternal memakai OAuth/OIDC library. State change memakai CSRF dan Origin. Authorization memakai RBAC. Aksi sensitif memakai audit log dan test.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.