Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 안전한 Cookie 관리 구현하기: Next.js 세션, CSRF, 동의 경계

Claude Code로 HttpOnly, Secure, SameSite, CSRF, 로그아웃, 동의 경계를 갖춘 안전한 Cookie를 구현합니다.

Claude Code로 안전한 Cookie 관리 구현하기: Next.js 세션, CSRF, 동의 경계

Cookie 관리는 단순히 로그인 상태를 저장하는 일이 아닙니다. 브라우저는 요청마다 Cookie를 자동으로 붙입니다. 그래서 작은 설정 실수 하나가 계정 탈취, CSRF, 로그아웃 실패, 동의 관리 오류로 이어질 수 있습니다.

Claude Code는 Cookie 코드를 빠르게 만들 수 있지만, “로그인 Cookie 만들어줘” 같은 짧은 지시는 위험합니다. HttpOnly가 빠지거나, SameSite=None인데 Secure가 없거나, 삭제할 때 Path가 달라서 Cookie가 남거나, 분석 Cookie와 인증 Cookie가 같은 동의 로직에 묶이는 일이 생깁니다.

이 글은 Next.js App Router 기준으로 안전한 Cookie 설계를 정리합니다. 실행 가능한 Route Handler, 로그아웃, 서버 측 읽기, CSRF token, session fixation 방지, 브라우저 동작, 동의 경계, 검증 명령까지 포함합니다. HttpOnly는 “JavaScript가 Cookie를 읽지 못하게 하는 표시”, SameSite는 “다른 사이트에서 온 요청에 Cookie를 붙일지 정하는 규칙”이라고 이해하면 됩니다.

먼저 Cookie를 분류한다

속성을 정하기 전에 목적을 정해야 합니다. 인증 Cookie는 사실상 자격 증명입니다. UI 선호 Cookie는 테마나 언어 같은 설정입니다. 분석과 광고 Cookie는 추적 인프라이므로 동의 경계가 따로 필요합니다.

목적권장 속성동의 경계
인증 세션__Host-sessionHttpOnly, Secure, SameSite=Lax, Path=/, 짧은 Max-Age보통 요청된 서비스에 필요한 Cookie지만 지역별 확인 필요
CSRF tokencsrf-tokenSecure, SameSite=Lax, 짧은 Max-Age보안 보조용이며 분석 ID로 쓰지 않음
UI 선호theme, localeSecure, SameSite=Lax, 제한된 수명정책과 지역에 따라 설명 필요
분석/광고_ga, campaign ID동의가 필요한 경우 동의 후 설정로그인과 결제 Cookie와 분리

MDN의 Secure cookie configurationSecure, HttpOnly, SameSite, Cookie prefix로 범위를 좁히라고 설명합니다. MDN Set-Cookie 문서도 SameSite=None에는 Secure가 필요하고, Max-AgeExpires가 함께 있으면 Max-Age가 우선한다고 안내합니다.

인증 Cookie는 가능하면 __Host- prefix를 사용합니다. 지원 브라우저에서는 Secure가 있어야 하고, Domain을 지정할 수 없고, Path=/여야 합니다. 이 조합은 하위 도메인이 같은 이름의 세션 Cookie를 덮어쓰는 위험을 줄입니다.

Next.js 공식 cookies APIcookies()를 비동기 함수로 설명하며 httpOnly, secure, sameSite, maxAge, path, domain 등의 옵션을 지원합니다. Server Component는 Cookie를 읽을 수 있지만, 설정과 삭제는 Route Handler 또는 Server Action에서 처리해야 합니다.

다음 코드는 app/api/login/route.ts에 넣어 바로 헤더를 확인할 수 있는 예시입니다. 세션 저장은 데모용 메모리 Map입니다. 운영에서는 Redis, PostgreSQL, DynamoDB 같은 영속 저장소로 바꾸세요.

import { createHmac, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

export const runtime = "nodejs";

const env = z
  .object({
    NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
    SESSION_SECRET: z.string().min(32),
  })
  .parse(process.env);

const SESSION_COOKIE = "__Host-session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;

type SessionRecord = {
  userId: string;
  expiresAt: number;
};

declare global {
  var demoSessions: Map<string, SessionRecord> | undefined;
}

const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12),
});

function createSessionToken() {
  const id = randomBytes(32).toString("base64url");
  const signature = createHmac("sha256", env.SESSION_SECRET)
    .update(id)
    .digest("base64url");

  return `${id}.${signature}`;
}

async function authenticate(email: string, password: string) {
  if (email === "masa@example.com" && password === "correct-horse-battery-staple") {
    return { id: "user_123" };
  }

  return null;
}

export async function POST(request: NextRequest) {
  const body = loginSchema.safeParse(await request.json());
  if (!body.success) {
    return NextResponse.json({ error: "Invalid login payload" }, { status: 400 });
  }

  const user = await authenticate(body.data.email, body.data.password);
  if (!user) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const token = createSessionToken();
  sessions.set(token, {
    userId: user.id,
    expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
  });

  const response = NextResponse.json({ ok: true });
  response.cookies.set({
    name: SESSION_COOKIE,
    value: token,
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: SESSION_MAX_AGE_SECONDS,
  });

  return response;
}

이 코드는 로그인 성공 때마다 새 token을 발급합니다. 이것이 session fixation, 즉 공격자가 미리 준비한 세션 ID로 사용자가 로그인하게 만드는 공격을 막는 기본입니다. OWASP의 Session Fixation 설명처럼, 로그인 시 새 세션 ID를 발급하지 않는 애플리케이션이 위험합니다.

로그아웃과 서버 측 읽기

Cookie 삭제는 이름만 맞춘다고 끝나지 않습니다. 브라우저는 이름과 scope를 함께 봅니다. 발급할 때 Path=/였다면 삭제할 때도 Path=/여야 합니다. Domain을 썼다면 삭제할 때도 같은 Domain이 필요합니다. __Host-Domain을 금지하므로 사고가 줄어듭니다.

app/api/logout/route.ts:

import { NextResponse } from "next/server";

const SESSION_COOKIE = "__Host-session";

export async function POST() {
  const response = NextResponse.json({ ok: true });

  response.cookies.set({
    name: SESSION_COOKIE,
    value: "",
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 0,
  });

  return response;
}

운영에서는 서버의 session store에서도 해당 세션을 무효화해야 합니다. 브라우저 Cookie만 지우면 현재 브라우저에서는 사라지지만, 탈취된 token이 서버에서 계속 살아 있을 수 있습니다.

서버에서 읽을 때는 await cookies()를 사용합니다.

import { cookies } from "next/headers";
import { redirect } from "next/navigation";

const SESSION_COOKIE = "__Host-session";

export default async function AccountPage() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get(SESSION_COOKIE)?.value;

  if (!sessionToken) {
    redirect("/login");
  }

  return <main>Account dashboard</main>;
}

Cookie가 있다고 해서 인증된 사용자는 아닙니다. 서버는 세션 존재 여부, 만료 시간, 폐기 상태, 사용자 권한을 반드시 확인해야 합니다.

CSRF는 HttpOnly만으로 막히지 않는다

CSRF는 다른 사이트가 로그인된 사용자의 브라우저를 이용해 원치 않는 요청을 보내게 만드는 공격입니다. 브라우저가 Cookie를 자동으로 보내기 때문에 HttpOnly는 기밀성만 보호할 뿐 요청 전송 자체를 막지 못합니다.

OWASP CSRF Prevention Cheat Sheet는 상태를 바꾸는 요청에 CSRF token을 넣고 서버에서 검증하라고 권장합니다. 아래 helper는 session token에 묶인 서명 token을 생성합니다.

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const CSRF_SECRET = process.env.SESSION_SECRET;
if (!CSRF_SECRET || CSRF_SECRET.length < 32) {
  throw new Error("SESSION_SECRET must be at least 32 characters");
}

export function createCsrfToken(sessionToken: string) {
  const nonce = randomBytes(16).toString("base64url");
  const signature = createHmac("sha256", CSRF_SECRET)
    .update(`${sessionToken}.${nonce}`)
    .digest("base64url");

  return `${nonce}.${signature}`;
}

export function verifyCsrfToken(sessionToken: string, token: string) {
  const [nonce, signature] = token.split(".");
  if (!nonce || !signature) return false;

  const expected = createHmac("sha256", CSRF_SECRET)
    .update(`${sessionToken}.${nonce}`)
    .digest("base64url");

  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

POST, PUT, PATCH, DELETE에는 token을 요구하세요. GET에서 서버 상태를 바꾸면 SameSite=Lax도 충분하지 않습니다. 또한 XSS가 있으면 CSRF 방어가 우회될 수 있으므로 출력 이스케이프와 CSP도 함께 봐야 합니다.

브라우저 동작과 만료 설정

브라우저는 Set-Cookie 헤더를 프론트엔드 JavaScript에 노출하지 않습니다. DevTools나 curl -i에서는 보이지만 fetch() 응답 헤더에서는 읽을 수 없습니다. 교차 출처 요청에서는 CORS와 credentials 설정도 함께 맞아야 Cookie가 기대대로 오갑니다.

Max-Age는 지금부터 몇 초 뒤 만료되는지입니다. Expires는 특정 날짜와 시간입니다. 둘 다 있으면 Max-Age가 우선합니다. 운영 코드에서는 시계 차이 영향을 줄이기 위해 Max-Age가 다루기 쉽습니다.

SameSite=Strict는 강하지만 외부 링크에서 들어오는 흐름을 불편하게 만들 수 있습니다. SameSite=Lax는 일반적인 로그인에는 균형이 좋지만, CSRF token을 대체하지 않습니다. SameSite=None은 정말 cross-site context가 필요한 경우에만 쓰고 반드시 Secure를 함께 설정합니다.

동의 경계와 사용 사례

동의 경계는 서비스 제공에 필요한 Cookie와 분석, 광고, 실험용 Cookie를 분리하는 기준입니다. 유럽연합 집행위원회의 Cookies policy도 인증, 동의 저장, 분석 목적을 분리해서 설명합니다. 법률 조언은 아니지만, 엔지니어링 원칙은 분명합니다. 인증 보안 Cookie와 광고 분석 Cookie를 같은 목적으로 묶지 마세요.

첫 번째 사례는 SaaS 로그인입니다. __Host-session, 짧은 Max-Age, 서버 측 폐기, CSRF token, 로그인 시 새 token 발급이 기본입니다. 관리자나 결제 변경에는 SameSite=Strict와 추가 인증도 고려합니다.

두 번째 사례는 콘텐츠 사이트입니다. 무료 PDF, 제품 구매, 상담 CTA를 측정하고 싶어도 분석 Cookie를 거부한 독자의 로그인, 다운로드, 구매, 문의는 깨지면 안 됩니다.

세 번째 사례는 언어와 테마 설정입니다. JavaScript에서 읽어야 하므로 HttpOnly가 아닐 수 있습니다. 그래서 더더욱 token, 역할, 가격, 권한을 넣으면 안 됩니다.

자주 나는 실패

첫째, __Host-sessionSecure가 없거나 Domain을 붙이거나 Path=/를 빼는 경우입니다. prefix가 주는 보안 이점이 사라집니다.

둘째, 로그아웃이 Cookie를 지우지 못하는 경우입니다. 대부분 발급과 삭제의 Path 또는 Domain이 다릅니다.

셋째, SameSite를 CSRF 대책 전체로 착각하는 경우입니다. token, 안전한 HTTP method, Origin 검증을 함께 봐야 합니다.

넷째, session token을 localStorage나 client-readable Cookie에 넣는 경우입니다. XSS가 발생하면 바로 노출됩니다.

다섯째, 동의 배너가 보안 Cookie까지 막는 경우입니다. 분석 거부가 로그인, 장바구니, 결제, CSRF 보호를 망가뜨려서는 안 됩니다.

Prompt와 검증

Claude Code에는 보안 계약을 명시합니다.

Next.js App Router 로그인 Cookie를 구현해 주세요.

요구사항:
- Cookie 이름은 __Host-session
- HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age를 명시
- Domain은 설정하지 않음
- 로그인 성공마다 새 session token 발급
- 로그아웃은 같은 Path와 Max-Age=0으로 삭제
- 분석 동의 Cookie와 인증 Cookie를 섞지 않음
- 상태 변경 요청의 CSRF token 방식을 설명
- MDN, Next.js, OWASP 문서를 기준으로 review

헤더 확인:

curl -i -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"masa@example.com","password":"correct-horse-battery-staple"}'

기대 형태:

Set-Cookie: __Host-session=...; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax

로그아웃 확인:

curl -i -X POST http://localhost:3000/api/logout

응답에 같은 Cookie 이름, Path=/, Max-Age=0이 있어야 합니다. Playwright에서는 context.cookies()httpOnly, secure, sameSite, 만료 시간을 확인합니다.

링크, CTA, 테스트 결과

인증 전체 흐름은 Claude Code 인증 구현 가이드, JWT 비교는 JWT 인증 글, 보안 review는 보안 감사 가이드를 이어서 보면 좋습니다. 공식 자료는 MDN Set-Cookie, Next.js cookies, OWASP Session Management, OWASP CSRF Prevention입니다.

반복 가능한 prompt, 구현 checklist, review template이 필요하면 ClaudeCodeLab의 제품과 템플릿을 확인하세요. 실제 팀 repository에 Cookie, 동의, checkout, 보안 review 규칙을 적용해야 한다면 교육 및 상담이 더 적합합니다.

실제로 이 흐름을 적용해 보니, Claude Code에게 __Host-, 로그아웃 scope, CSRF token, 분석 동의 경계를 처음부터 명시했을 때 수정 횟수가 줄었습니다. 반대로 “Cookie를 안전하게 해줘”라는 짧은 요청은 SameSite와 삭제 처리에서 빠지는 부분이 많았습니다.

#Claude Code #Cookie #session #security #Next.js #TypeScript
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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