프론트엔드 개발자가 알아야 할 CSRF (with Next.js)

2025. 9. 8. 07:30·IT/Front-End

들어가며

 

최근 들어 국내외에서 보안 사고가 끊이지 않고 있습니다. yes24, 롯데카드와 같은 대형 서비스에서도 해킹 사례가 발생하는 것을 보고, 저 또한 개발자로서 보안에 관심을 가지게 되었습니다.

 

저는 프론트엔드 개발을 하면서 보안은 주로 백엔드나 인프라 영역의 문제라고만 생각해왔습니다. 그러나 실제로는 브라우저의 기본 동작, 쿠키 관리 방식, 그리고 우리가 작성하는 요청 코드 하나하나가 보안 취약점과 직접적으로 연결되어 있음을 알게 되었습니다.

 

그중에서도 특히 CSRF는 프론트엔드 개발자라면 반드시 이해해야 하는 주제라고 생각합니다! 이 글에서는 CSRF의 개념을 하나씩 정리하며, 실무에서는 어떻게 방어할 수 있는지 학습했고 이를 글로써 작성해보았습니다.

 


CSRF란 무엇일까?

CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 사용자가 의도하지 않은 요청을 브라우저가 자동으로 실행하게 만들어, 결과적으로 공격자가 원하는 동작이 서버에서 수행되도록 하는 공격 기법입니다.

 

  1. 사용자가 A 서비스(예: 은행 사이트)에 로그인합니다. 이때 서버는 세션을 유지하기 위해 브라우저에 쿠키를 내려줍니다.
  2. 사용자가 로그인한 상태로 다른 웹사이트, 예를 들어 공격자가 만든 악성 사이트를 방문합니다.
  3. 악성 사이트에는 A 서비스로 향하는 폼 전송이나 스크립트가 심어져 있습니다. 사용자가 버튼을 누르거나 심지어 단순히 페이지를 열기만 해도, 브라우저는 자동으로 A 서비스의 쿠키를 함께 첨부하여 요청을 보냅니다.
  4. A 서비스 입장에서는 “쿠키가 붙어 있으니 정상 사용자 요청”으로 오인하고, 계좌 이체나 비밀번호 변경 같은 크리티컬한 작업을 수행하게 됩니다.

이 과정에서 중요한 점은 브라우저가 알아서 쿠키를 붙여주는 기본 동작 때문에, 사용자가 의도하지 않은 요청도 서버 입장에서는 정상적인 요청처럼 보인다는 점입니다.

 


쿠키와 헤더의 차이

CSRF가 발생하는 배경에는 브라우저가 인증 정보를 다루는 방식의 차이가 있습니다. 대표적으로 쿠키와 헤더는 같은 “인증 수단”이지만 동작 원리가 크게 다릅니다.

 

 

먼저 쿠키는 서버가 Set-Cookie 헤더를 내려주면 브라우저가 이를 저장해두었다가, 동일한 도메인, 경로, 프로토콜 규칙에 맞는 요청이 발생할 때마다 자동으로 붙여 보냅니다. 개발자가 fetch나 axios 코드에서 따로 지정하지 않아도 쿠키는 항상 따라갑니다. 사용자는 이 덕분에 로그인 상태를 유지할 수 있지만, 동시에 원치 않는 상황에서도 쿠키가 자동 전송된다는 문제가 있습니다. 바로 이 지점이 CSRF 공격의 주요 진입로가 됩니다.

 

반면 헤더는 상황이 다릅니다. 예를 들어 Authorization이나 X-API-Key 같은 헤더는 브라우저가 알아서 붙여주지 않습니다. 개발자가 직접 요청 코드에 작성해야만 서버로 전송됩니다.

await fetch("/api/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

 

브라우저가 임의로 민감한 헤더를 자동 전송하지 않는 이유는 명확합니다. 만약 자동으로 전송된다면, 사용자가 의도하지 않은 다른 사이트로 토큰이 흘러가는 보안 문제가 발생할 수 있기 때문입니다.

결국 차이는 이렇습니다.

  • 쿠키는 브라우저가 알아서 관리하고 전송해주기 때문에 편리하지만 CSRF 위험이 큽니다.
  • 헤더는 개발자가 명시적으로 설정해야만 전송되므로 의도치 않은 요청이 발생할 가능성이 적습니다.

즉, 브라우저의 친절한 기본 동작(쿠키 자동 첨부)이 아이러니하게도 CSRF 공격의 표면을 넓히는 원인이 되는 셈입니다.

 


CSRF 방어 방법

CSRF는 브라우저의 기본 동작을 악용하는 공격이기 때문에, 서버와 클라이언트 모두에서 이를 인지하고 방어해야 합니다. 대표적으로 다음 네 가지 방법이 널리 사용됩니다.

 

(1) CSRF 토큰

가장 전통적이고 널리 쓰이는 방법입니다. 서버가 사용자 세션과 연결된 랜덤 토큰을 발급하고, 클라이언트는 폼 전송이나 요청 시 이 토큰을 함께 제출해야 합니다. 서버는 토큰을 검증하여 요청이 정상적으로 발급된 것인지 확인합니다. 공격자는 이 토큰을 알 수 없으므로 위조 요청이 차단됩니다.

// 서버에서 토큰 발급
const token = crypto.randomUUID();
res.cookie("csrf-token", token, { httpOnly: true });


// 클라이언트에서 요청 시 헤더에 추가
await fetch("/api/transfer", {
  method: "POST",
  headers: {
    "X-CSRF-Token": token,
  },
  body: JSON.stringify({ amount: 1000 }),
});

 

(2) SameSite 쿠키 설정

Set-Cookie 헤더에 SameSite 속성을 설정하면, 다른 사이트에서 발생한 요청에는 쿠키가 자동 전송되지 않습니다.

  • Strict: 다른 사이트에서의 요청에는 아예 쿠키를 전송하지 않음
  • Lax: 기본값, 링크 클릭이나 GET 요청은 허용하지만 POST 같은 민감 요청은 차단
  • None: 제3자 쿠키도 허용 (단, Secure 필요)
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Lax

 

(3) Origin 혹은 Referer 검증

서버가 요청의 Origin 혹은 Referer 헤더를 검사하여, 허용된 도메인에서 온 요청인지 확인하는 방법입니다. 다만 일부 환경에서는 헤더가 비어 있을 수도 있어, 보조적인 방어 수단으로 쓰입니다.

 

(4) 추가 인증 절차

특히 민감한 동작(계좌 이체, 비밀번호 변경 등)에는 비밀번호 재입력이나 OTP 같은 2차 인증을 요구하는 방법입니다. 공격자가 요청을 위조하더라도 사용자의 추가 입력이 없으면 성공할 수 없습니다. CAPTCH 처럼 봇이나 자동화 공격을 방지하는 보조 수단도 존재합니다.

 

(5) 기타

  • CORS 설정: 서버에서 교차 출처 요청을 제한하면, 악성 사이트가 브라우저를 통한 직접 호출을 하기 어려워집니다. 다만 CSRF 자체를 완벽히 막는 수단은 아닙니다.

 


Next.js 프레임워크 예시

제가 메인으로 사용하고 있는 Next.js 15 프레임워크에서 CSRF를 어떻게 방어할 수 있는 살펴보겠습니다.

 

 

우선 Next.js는 CSRF를 자동으로 방어해주지 않습니다.

 

App Router의 Route Handler(예: app/api//route.ts)나 Server Actions는 들어온 요청을 검증 없이 그대로 핸들러로 전달합니다. 즉, 쿠키 기반 세션을 쓴다면 CSRF 방어는 개발자가 직접 구현해야 합니다. 반대로 헤더 기반(JWT Bearer 등)은 브라우저가 자동 전송하지 않기 때문에 CSRF 표면이 상대적으로 작아집니다(대신 토큰 탈취와 XSS에 특히 주의).

 

App Router에서 바로 쓸 수 있는 CSRF 방어 패턴에 대해 살펴보겠습니다.

(1) CSRF 토큰(Double Submit / Form Token)

  • 아이디어: 서버가 랜덤 토큰을 발급해 쿠키와 요청 바디/헤더에 동시에 담아 보내게 하고, 서버에서 둘을 비교해 일치하면 통과.
  • 장점: 프레임워크에 의존하지 않음. 폼/Server Actions에 자연스럽게 녹아듦.
  • 주의: 토큰 재사용/만료, 경로 스코프, HTTPS 전제.

서버: 토큰 발급(예: 페이지 렌더링 시)

// app/(marketing)/page.tsx (Server Component)
import { cookies } from "next/headers";
import crypto from "node:crypto";

export default async function Page() {
  const token = crypto.randomUUID(); // or crypto.randomBytes(32).toString("base64url")
  cookies().set("csrf", token, {
    httpOnly: false,          // Double-submit 용도: 클라이언트에서 읽을 수 있어야 함
    sameSite: "lax",
    secure: true,
    path: "/",
  });

  return (
    <form action="/api/transfer" method="POST">
      <input type="hidden" name="csrf" value={token} />
      {/* ... */}
      <button type="submit">이체</button>
    </form>
  );
}

 

API 라우트: 토큰 검증

// app/api/transfer/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const form = await req.formData();
  const tokenFromBody = form.get("csrf");
  const tokenFromCookie = req.cookies.get("csrf")?.value;

  if (!tokenFromBody || !tokenFromCookie || tokenFromBody !== tokenFromCookie) {
    return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
  }

  // ... 실제 비즈니스 로직
  return NextResponse.json({ ok: true });
}

 

만약 XHR/fetch 기반이라면, hidden input 대신 커스텀 헤더(X-CSRF-Token)로 보냅니다. 이때 토큰은 document.cookie에서 읽어 헤더에 실어보내면 됩니다.

 


(2) Server Actions와 함께 쓰는 CSRF 토큰 예시

아이디어: 서버 컴포넌트 렌더링 시 토큰을 발급 => 쿠키로 저장(클라이언트에서 읽을 수 있어야 하므로 httpOnly: false) => 폼 hidden input에 같은 토큰 삽입 => Server Action 내부에서 쿠키/폼 값을 비교하여 검증.

 

// app/transfer/page.tsx (Server Component)
import { cookies } from "next/headers";
import crypto from "node:crypto";

export default async function TransferPage() {
  // 1) CSRF 토큰 발급 및 쿠키 설정
  const token = crypto.randomUUID();
  cookies().set("csrf", token, {
    httpOnly: false,   // Double-submit 패턴: 클라이언트에서 읽어 hidden input에 넣기 위함
    sameSite: "lax",
    secure: true,
    path: "/",
  });

  // 2) Server Action 정의
  async function transferAction(formData: FormData) {
    "use server";

    // 쿠키의 토큰과 폼의 토큰 비교
    const tokenFromCookie = cookies().get("csrf")?.value;
    const tokenFromForm = formData.get("csrf");

    if (!tokenFromCookie || !tokenFromForm || tokenFromCookie !== tokenFromForm) {
      // 실패 처리(에러 페이지/메시지/로그 등)
      throw new Error("Invalid CSRF token");
    }

    // 안전 통과: 실제 비즈니스 로직
    const amount = Number(formData.get("amount") || 0);
    // ... 송금 처리 등
    // 성공 후 redirect/revalidate 처리 가능
    // redirect("/transfer/success")
  }

  // 3) 폼에 hidden input으로 동일 토큰 삽입
  return (
    <form action={transferAction}>
      <input type="hidden" name="csrf" value={token} />
      <label className="block mb-2">
        이체 금액
        <input name="amount" type="number" className="border p-2 ml-2" />
      </label>
      <button type="submit" className="border px-3 py-1">이체</button>
    </form>
  );
}

 

참고

  • 이 예시는 Double-Submit Cookie 패턴입니다. 토큰을 쿠키와 폼에 동시에 담아 보내고, 서버에서 둘을 비교합니다.
  • httpOnly: false를 사용하는 이유는 클라이언트에서 토큰 값을 읽어 hidden input에 넣기 위함입니다(대신 XSS에 더욱 주의해야함).

 


(3) SameSite 쿠키 + Origin/Referer 검증(보조)

  • SameSite=Lax/Strict로 제3자 컨텍스트에서 쿠키 자동 전송을 제한합니다.
  • Origin/Referer를 화이트리스트로 검사해 다른 출처에서 온 state-changing 요청을 차단합니다.

쿠키 발급 예시

// 예: 로그인 응답에서 세션 쿠키 발급
import { cookies } from "next/headers";

cookies().set("session", "<opaque>", {
  httpOnly: true,
  secure: true,
  sameSite: "lax", // 중요한 트랜잭션은 Strict 고려
  path: "/",
});

 

middleware 에서 Origin/Referer 체크(POST/PUT/PATCH/DELETE만)

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const ALLOWED = new Set(["https://your.app", "https://www.your.app"]);

export function middleware(req: NextRequest) {
  const method = req.method.toUpperCase();
  if (!["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
    return NextResponse.next();
  }

  const origin = req.headers.get("origin");
  const referer = req.headers.get("referer");

  // Origin 우선 검사 => 없으면 Referer의 오리진 추출
  const url = origin ?? (referer ? new URL(referer).origin : null);
  if (!url || !ALLOWED.has(url)) {
    return new NextResponse("Forbidden (CSRF check)", { status: 403 });
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

 

1) 실무 조합 가이드

  • 쿠키 세션의 경우
    • CSRF 토큰(필수) + SameSite=Lax/Strict + Origin/Referer 체크(보조) 조합을 기본값으로.
    • 민감 기능(송금, 비번 변경)은 추가 인증(비번 재입력/OTP)로 한 번 더 잠가두기.
  • 헤더 기반(JWT Bearer 등)의 경우
    • CSRF 노출면은 작지만 XSS, 토큰 탈취 위험이 커집니다. HttpOnly 쿠키를 포기했다면 보호 헤더(Content-Security-Policy 등)와 보관, 갱신 전략을 촘촘히 설계하세요.

 


iron-session은 뭐하는 친구지?

저는 실무에서 iron-session을 무의식적으로 사용하곤 합니다. 사내 프로젝트에 기본 세팅되어 있기도 하고, Next.js 공식 문서에서도 권장하는 도구라 별 의심없이 사용했습니다. 그런데 이번에 CSRF에 대해 공부하며 이 친구에 대해 궁금해졌습니다.

 

https://nextjs.org/docs/app/guides/authentication#session-management-libraries

 

Guides: Authentication | Next.js

Learn how to implement authentication in your Next.js application.

nextjs.org

 

Next.js 공식 문서에서 세션 관리(Session Management) 도구로 권장하는 라이브러리 중 하나인 iron-sesison은, 서버가 별도 DB를 두지 않고도 암호화(및 서명)된 세션 데이터를 쿠키에 저장하도록 도와주는 라이브러리입니다. 따라서 iron-session이 쿠키를 더 안전하게 만들어줄 수는 있어도, 쿠키를 자동 전송하는 브라우저의 특성은 변함없기 때문에 CSRF에는 여전히 취약합니다.

 

그러나 앞서 살펴본 CSRF 토큰 패턴을 iron-session과 조합하여 보안을 강화할 수도 있습니다.

 


마치며

프론트엔드 개발자 입장에서는 “서버에서 막아주겠지”라고 생각하기 쉽지만, 사실 쿠키의 자동 전송 방식이나 인증 전략 자체가 브라우저 동작과 깊게 맞물려 있기 때문에 클라이언트 로직을 작성하는 개발자 역시 반드시 이해해야 할 주제라는 점을 느꼈습니다.

 

특히 Next.js 15 처럼 프레임워크가 인증, 세션, CSRF 방어를 대신 처리해주지 않는 환경에서는, 우리가 어떤 인증 전략(쿠키 세션인지, 헤더 기반 토큰인지)을 선택하느냐에 따라 방어 방식도 달라집니다. iron-session 같은 라이브러리를 사용하면 세션 쿠키 자체는 안전하게 관리할 수 있지만, CSRF 방어는 여전히 직접 챙겨야 한다는 점을 잊지 말아야 할 것 같습니다!

 


참조

https://developer.mozilla.org/ko/docs/Glossary/CSRF

 

교차 사이트 요청 위조 (CSRF) - MDN Web Docs 용어 사전: 웹 용어 정의 | MDN

 

developer.mozilla.org

https://developers.hyundaimotorgroup.com/blog/465

 

FrontEnd 개발에서의 보안 - CSRF

FrontEnd 개발을 할 때 고려해보면 좋을 보안에 대해 이야기합니다. 그중 CSRF(Cross Stie Request Forgery)에 대해 다루어봅니다. 이를 통해 세션, 쿠키 등에 알아보고 해당 공격에 효과적으로 대응하기 위

developers.hyundaimotorgroup.com

 

'IT > Front-End' 카테고리의 다른 글

프론트엔드 개발자가 알아야 할 SEO  (6) 2025.10.31
프론트엔드 개발자가 알아야 할 쿠키 정책  (0) 2025.09.06
JS 개발자를 위한 용어 정리: 모듈 vs 패키지 vs 라이브러리  (15) 2025.08.05
나의 학습 방법 : 아티클 구독  (11) 2025.07.26
프론트? 그거 비전공자나 하는 거잖아: 추상화가 만든 착시  (5) 2025.07.20
'IT/Front-End' 카테고리의 다른 글
  • 프론트엔드 개발자가 알아야 할 SEO
  • 프론트엔드 개발자가 알아야 할 쿠키 정책
  • JS 개발자를 위한 용어 정리: 모듈 vs 패키지 vs 라이브러리
  • 나의 학습 방법 : 아티클 구독
KimCookieYa
KimCookieYa
무엇이 나를 살아있게 만드는가
  • KimCookieYa
    쿠키의 주저리
    KimCookieYa
  • 전체
    오늘
    어제
    • 분류 전체보기 (592)
      • 혼잣말 (90)
      • TIL (3)
      • 커리어 (29)
        • Sendy (24)
        • 외부활동 기록 (4)
      • 프로젝트 (186)
        • 티스토리 API (5)
        • 코드프레소 체험단 (89)
        • Web3 (3)
        • Pint OS (16)
        • 나만무 (14)
        • 대회 (6)
        • 정글 FE 스터디 (16)
        • MailBadara (12)
        • github.io (1)
        • 인공지능 동아리, AID (5)
        • 졸업과제 (18)
        • OSSCA 2024 (1)
      • 크래프톤 정글 2기 (80)
      • IT (176)
        • 코딩 (5)
        • CS (18)
        • 에러 (5)
        • 블록체인 (23)
        • Front-End (46)
        • 알고리즘&자료구조 정리 (3)
        • 코딩테스트 (3)
        • BOJ 문제정리 (41)
        • WILT (12)
        • ML-Agents (4)
        • 강화학습 (1)
        • Android (0)
        • LLM (2)
      • 전공 (1)
        • 머신러닝 (1)
      • 자기계발 (22)
        • 빡공단X베어유 (2)
        • 독서 (17)
  • 블로그 메뉴

    • 홈
    • 방명록
    • Github
    • Velog
    • 관리
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    numpy
    NEAR Protocol
    해커톤
    Pint OS
    react
    프로그래머스
    니어프로토콜
    글리치해커톤
    사이드프로젝트
    핀토스
    파이썬
    sendy
    RNN
    부산대
    MailBadara
    pintos
    자바스크립트
    리액트
    크래프톤정글
    블록체인
    센디
    알고리즘
    Flutter
    머신러닝
    JavaScript
    나만무
    졸업과제
    코드프레소
    OS
    딥러닝
  • hELLO· Designed By정상우.v4.10.3
KimCookieYa
프론트엔드 개발자가 알아야 할 CSRF (with Next.js)
상단으로

티스토리툴바