Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 안전한 인증 구현하기: Next.js 세션, JWT 경계, OAuth

Claude Code로 Next.js 인증을 안전하게 구현합니다. 세션, JWT, OAuth, CSRF, RBAC, 감사 로그와 테스트를 다룹니다.

Claude Code로 안전한 인증 구현하기: Next.js 세션, JWT 경계, OAuth

인증 구현은 로그인 폼 하나로 끝나지 않습니다. 비밀번호 저장, 세션 Cookie, JWT, OAuth callback, CSRF, 비밀번호 재설정, RBAC, secrets, 감사 로그, 테스트가 함께 설계되어야 합니다. Claude Code에게 “auth를 추가해줘”라고만 요청하면 화면은 동작하지만 운영 중 취소하거나 추적하기 어려운 위험이 남을 수 있습니다.

가장 흔한 위험한 지름길은 긴 수명의 JWT를 브라우저 localStorage에 저장하는 방식입니다. XSS, 즉 악성 JavaScript가 페이지 안에서 실행되는 취약점이 생기면 token을 그대로 빼앗길 수 있습니다. 일반적인 웹 앱에서는 서버 측 세션을 저장하고, 브라우저에는 추측하기 어려운 세션 ID만 HttpOnly Cookie로 내려주는 방식이 더 다루기 쉽습니다.

이 글은 Next.js App Router 기준으로 복사해 볼 수 있는 최소 구현을 제공합니다. Zod validation, bcrypt password hashing, signed opaque session cookie, middleware guard, CSRF와 Origin check, RBAC, audit log, Vitest test를 포함합니다. JWT는 짧은 수명의 API/mobile 경계로만 다루고, OAuth는 외부 identity provider에 인증을 맡기는 경계로 봅니다.

공식 문서는 원문을 기준으로 확인하세요. 이 글은 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, Claude Code docs를 기준으로 합니다. 함께 보면 좋은 내부 글은 Cookie 관리, RBAC 구현, Zod validation입니다.

세션, JWT, OAuth를 분리하기

세션은 “이 브라우저가 아직 로그인 상태인가”를 판단합니다. JWT는 “짧은 시간 동안 서명된 claim을 증명할 수 있는가”를 판단합니다. OAuth/OIDC는 “신뢰하는 provider가 이 사용자를 인증했는가”를 판단합니다. 셋을 같은 함수에 섞으면 review가 어려워집니다.

방식적합한 곳장점주의점
Server-side sessionSaaS dashboard, member area, admin UIrevoke, forced logout, audit가 쉽다Redis, Postgres, DynamoDB 같은 store가 필요
JWTmobile API, short-lived service call, external APIDB 조회 없이 signature 검증 가능revoke가 어렵고 browser 장기 저장은 피해야 함
OAuth / OIDCGoogle, GitHub, 사내 SSOpassword를 직접 저장하지 않아도 됨provider login 이후에도 local authorization이 필요

실무 use case는 세 가지가 많습니다. SaaS dashboard는 HttpOnly session cookie로 로그인 상태를 유지하고, billing이나 email 변경에는 재인증을 요구합니다. 유료 콘텐츠 사이트는 무료 독자, 구매자, 편집자, 관리자 권한을 RBAC로 나누고 audit log를 남깁니다. 사내 B2B tool은 Google Workspace나 Entra ID로 로그인하더라도 tenant boundary, role, session expiry는 애플리케이션이 직접 확인해야 합니다.

Claude Code에 줄 프롬프트

코드를 만들기 전에 보안 경계를 먼저 전달합니다.

Next.js App Router 앱에 인증을 구현하세요.
조건:
- 브라우저 로그인은 server-side session + HttpOnly Cookie를 사용
- JWT는 short-lived external API token으로만 사용하고 localStorage에 저장하지 않음
- password는 bcrypt 또는 Argon2id로 hash하고 plaintext 저장 금지
- 입력은 Zod로 validate하고 email 존재 여부를 에러 메시지로 노출하지 않음
- Cookie에는 Secure, HttpOnly, SameSite, Path, Max-Age를 명시
- state-changing API는 Origin check와 CSRF token을 확인
- OAuth는 직접 구현하지 말고 Auth.js 같은 검증된 library를 우선 사용
- RBAC, password reset, audit log, tests를 포함
- 마지막에 pitfalls와 verification command를 출력

이 프롬프트는 코드 양을 늘리는 목적이 아니라, Claude Code가 피해야 할 설계를 먼저 고정하는 장치입니다. 인증은 첫 코드보다 경계와 테스트가 더 중요합니다.

복사해서 실행하는 Next.js 최소 구현

필요한 dependency를 설치합니다.

npm install zod bcryptjs
npm install -D vitest typescript @types/node

.env.local에는 32자 이상 secret을 둡니다. Git에 commit하지 말고 deployment secret store를 사용하세요.

SESSION_SECRET="replace-with-at-least-32-random-characters"

lib/auth/password.ts입니다. OWASP는 Argon2id를 우선 후보로 설명합니다. 여기서는 복사하기 쉬운 bcryptjs를 사용합니다.

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);
}

lib/auth/session.ts입니다. 메모리 Map은 demo용입니다. production에서는 Redis, PostgreSQL, DynamoDB 같은 shared store로 바꾸세요.

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");
}

app/api/login/route.ts입니다. 실제 서비스에서는 database에서 hash와 role을 가져옵니다.

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는 route guard입니다. 실제 authorization은 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/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, JWT 경계

Password reset은 email 발송만의 문제가 아닙니다. 존재하는 email과 존재하지 않는 email에 같은 응답을 반환해야 사용자 enumeration을 줄일 수 있습니다. Reset token은 충분히 랜덤해야 하고, DB에는 hash만 저장하며, 짧은 만료 시간과 single-use 처리가 필요합니다. Password 변경 후 기존 session을 무효화할지 선택할 수 있어야 합니다.

OAuth는 직접 구현하지 않는 편이 안전합니다. Auth.js 같은 library로 provider callback을 처리하고, provider identity를 local user에 연결한 뒤 local session cookie를 발급합니다. Provider access token을 그대로 앱 session으로 쓰면 scope, expiry, audit 책임이 섞입니다.

JWT는 API 경계에서 유용하지만 만능은 아닙니다. 사용할 때는 짧은 exp, aud, iss, key rotation, leak response를 명시하세요.

Pitfalls, Audit Log, 수익화

배포 전 막아야 할 pitfall은 명확합니다. 긴 수명의 JWT를 localStorage에 저장하기, SecureHttpOnly 없는 Cookie, SHA-256 단독 password 저장, reset token plaintext 저장, email 존재 여부를 알려주는 login error, middleware만으로 RBAC 처리, password나 token을 audit log에 출력하는 일입니다.

Audit log에는 actor, action, result, time을 남기고 password, session ID, OAuth access token, reset token은 남기지 않습니다. SaaS에서는 role보다 먼저 tenantId를 확인합니다. 어떤 사용자가 A tenant의 admin이어도 B tenant invoice를 읽을 권한은 없습니다.

인증은 수익 경로도 보호합니다. 유료 콘텐츠, template 판매, Gumroad link, 기업 상담 form, member dashboard는 모두 신뢰할 수 있는 account boundary가 있어야 합니다. 먼저 무료 checklist로 Claude Code 확인 흐름을 고정하고, team 차원의 인증, RBAC, audit log, CI test가 필요하다면 Claude Code training/consultation에서 실제 repository 기준으로 설계할 수 있습니다.

실제로 시도한 결과

Masa가 이 방식으로 확인했을 때 효과가 컸던 부분은 login route 자체가 아니라 session cookie, CSRF, RBAC, audit log, test를 하나의 작업 단위로 묶은 점이었습니다. 예전에는 middleware가 security boundary처럼 보였지만 실제로는 cookie 존재만 확인했습니다. 중요한 check를 server route로 옮기니 review가 구체적이 되었고 admin page 오픈 실수를 줄일 수 있었습니다.

Summary

Claude Code로 인증을 구현할 때는 먼저 경계를 정하세요. Browser는 server-side session과 secure Cookie, API는 short-lived JWT, 외부 로그인은 OAuth/OIDC library, 상태 변경은 CSRF와 Origin check, 권한은 RBAC, 중요한 작업은 audit log입니다. Claude Code는 빠르게 구현할 수 있지만, 안전한 결과는 명확한 조건과 테스트에서 나옵니다.

#Claude Code #authentication #Next.js #JWT #OAuth #security
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.