Ray Book
웹 보안

CSRF와 인증 보안

사용자의 권한을 악용하는 CSRF 공격, SameSite 쿠키, CSRF 토큰, 인증 방식 비교를 시각화합니다

browsersecuritycsrfcookieauthentication

CSRF란

Cross-Site Request Forgery(CSRF)는 사용자가 의도하지 않은 요청을 보내게 만드는 공격입니다. 이전 글에서 다룬 XSS는 공격자의 코드를 사용자의 브라우저에서 실행시키는 공격이었습니다. CSRF는 다릅니다. 코드를 주입하지 않습니다. 대신 사용자의 인증된 세션을 악용합니다.

핵심 차이를 정리하면 이렇습니다. XSS는 사이트가 사용자를 신뢰하는 것을 악용합니다 -- 사이트에 주입된 코드가 사용자의 권한으로 동작합니다. CSRF는 사이트가 브라우저를 신뢰하는 것을 악용합니다 -- 브라우저가 보내는 쿠키를 서버가 무조건 신뢰하는 것이 문제입니다.

CSRF가 작동하려면 두 가지 조건이 필요합니다.

  • 피해자가 대상 사이트에 로그인한 상태여야 합니다 (유효한 세션 쿠키가 있어야 합니다)
  • 피해자가 공격자의 페이지를 방문하거나 악성 링크를 클릭해야 합니다

이 두 조건이 동시에 성립하면, 공격자는 피해자의 권한으로 송금, 비밀번호 변경, 설정 변경 같은 행동을 실행할 수 있습니다.

CSRF 공격 흐름

공격이 어떻게 진행되고, 방어가 어떻게 작동하는지 단계별로 확인해 보세요.

공격자
악성 페이지 준비:
https://evil.com/trap.html

<form action="https://bank.com/transfer"
      method="POST" id="f">
  <input name="to" value="attacker">
  <input name="amount" value="1000000">
</form>
<script>f.submit()</script>
공격자가 악성 페이지를 만듭니다. 이 페이지에는 은행 사이트로 송금 요청을 보내는 자동 제출 폼이 숨겨져 있습니다.

13단계는 공격의 흐름입니다. 공격자가 악성 페이지를 만들고, 피해자가 방문하면 브라우저가 쿠키와 함께 요청을 보내고, 서버가 이를 정상 요청으로 처리합니다. 45단계는 방어입니다. SameSite 쿠키와 CSRF 토큰이 각각 다른 방식으로 공격을 차단합니다.

왜 쿠키가 문제인가

CSRF가 가능한 근본적인 이유는 브라우저의 쿠키 전송 방식에 있습니다. 브라우저는 요청의 목적지 도메인에 해당하는 쿠키를 자동으로 포함합니다. 이 요청이 어디서 시작되었는지는 고려하지 않습니다.

사용자가 evil.com에서 bank.com으로 폼을 제출
→ 브라우저: "bank.com 쿠키가 있네? 포함해서 보내자"
→ 서버: "유효한 쿠키다. 정상 요청이군"

이것은 버그가 아니라 쿠키의 설계입니다. 쿠키는 원래 상태 유지를 위해 만들어졌고, "이 쿠키가 포함된 요청은 사용자가 의도한 것"이라는 보장은 처음부터 없었습니다. CSRF는 이 설계상의 빈틈을 파고드는 공격입니다.

<form>, <img>, <script> 태그를 통한 교차 출처 요청은 Same-Origin Policy의 제한을 받지 않습니다. SOP는 응답을 읽는 것을 차단하지, 요청을 보내는 것 자체를 막지는 않습니다. 그래서 evil.com의 폼이 bank.com으로 POST 요청을 보내는 것이 가능합니다.

SameSite 쿠키

SameSite 속성은 쿠키가 교차 사이트 요청에 포함될 조건을 제어합니다. 세 가지 값이 있습니다.

Strict -- 가장 엄격합니다. 다른 사이트에서 시작된 모든 요청에 쿠키를 포함하지 않습니다.

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly

외부 사이트에서 링크를 클릭해 이동해도 쿠키가 빠집니다. 보안은 강하지만 사용성에 영향이 있습니다. 예를 들어, 이메일의 링크로 은행 사이트에 접속하면 로그인이 풀려 있는 것처럼 보입니다.

Lax -- 기본값입니다. 대부분의 교차 사이트 요청에 쿠키를 포함하지 않지만, 안전한 최상위 탐색(top-level navigation)에는 포함합니다. 여기서 "안전한"이란 GET처럼 서버 상태를 변경하지 않는 메서드를 의미합니다.

Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
시나리오쿠키 포함 여부
링크 클릭으로 이동 (GET)포함
<form method="POST"> 교차 사이트 제출미포함
<form method="GET"> 교차 사이트 제출포함
<img src="..."> 교차 사이트미포함
fetch() / XMLHttpRequest 교차 사이트미포함

Lax가 기본값인 이유는 Strict의 사용성 문제를 해결하면서도 CSRF의 가장 위험한 시나리오(POST 요청)를 차단하기 때문입니다. 2020년 Chrome 80부터 SameSite=Lax를 기본값으로 적용했고, Chromium 기반 브라우저(Edge 등)도 따랐습니다. Firefox와 Safari는 동일한 기본값을 채택하지 않았지만, 유사한 방향의 쿠키 정책을 강화하고 있습니다.

None -- 모든 교차 사이트 요청에 쿠키를 포함합니다. 반드시 Secure 플래그와 함께 사용해야 합니다 (HTTPS 필수). 써드파티 쿠키가 필요한 경우(임베디드 위젯, OAuth 등)에만 사용합니다.

Set-Cookie: widget_session=xyz; SameSite=None; Secure; HttpOnly

CSRF 토큰

SameSite 쿠키가 보급되기 전에도 CSRF 방어는 가능했습니다. 그 전통적인 방법이 CSRF 토큰입니다.

서버가 폼을 렌더링할 때, 고유한 토큰을 생성하여 hidden 필드에 넣습니다. 같은 토큰을 서버 세션에도 저장합니다.

<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="a1b2c3d4e5f6">
  <input name="to" value="">
  <input name="amount" value="">
  <button type="submit">송금</button>
</form>

서버는 요청을 받으면 폼의 토큰과 세션의 토큰을 비교합니다.

// 서버 측 검증 (Express 예시)
app.post("/transfer", (req, res) => {
  const formToken = req.body._csrf;
  const sessionToken = req.session.csrfToken;

  if (!formToken || formToken !== sessionToken) {
    return res.status(403).send("CSRF token mismatch");
  }

  // 토큰 일치, 요청 처리
  processTransfer(req.body.to, req.body.amount);
});

공격자가 이 토큰을 알 수 있을까요? 알 수 없습니다. 공격자가 bank.com의 페이지를 fetch로 가져와서 토큰을 읽으려 해도, Same-Origin Policy가 응답 읽기를 차단합니다. evil.com에서 bank.com의 응답 본문을 읽을 수 없으므로 토큰을 추출할 수 없습니다.

CSRF 토큰의 변형으로 Double Submit Cookie 패턴도 있습니다. 토큰을 쿠키와 요청 본문 모두에 포함하고, 서버가 둘을 비교합니다. 공격자는 쿠키의 값을 읽을 수 없으므로 요청 본문에 올바른 토큰을 넣을 수 없습니다. 이 방식은 서버 측 세션 저장소가 필요하지 않다는 장점이 있습니다.

인증 방식 비교

CSRF 취약성은 인증 방식에 따라 달라집니다. 각 방식이 CSRF와 XSS에 얼마나 취약한지 비교해 봅시다.

쿠키 기반 세션JWT (localStorage)JWT (HttpOnly 쿠키)
인증 정보 위치쿠키localStorage쿠키
자동 전송 여부브라우저가 자동 포함수동으로 헤더에 추가브라우저가 자동 포함
CSRF 취약취약 (SameSite로 완화)안전취약 (SameSite로 완화)
XSS 취약HttpOnly면 직접 탈취 불가탈취 가능HttpOnly면 직접 탈취 불가
서버 상태세션 저장소 필요무상태(stateless)무상태(stateless)

쿠키 기반 세션 은 가장 전통적인 방식입니다. 서버가 세션 ID를 쿠키에 저장하고, 매 요청마다 브라우저가 자동으로 보냅니다. 자동 전송이 편리하지만, 그것이 곧 CSRF의 원인입니다. SameSite 쿠키와 CSRF 토큰으로 보완해야 합니다.

JWT를 localStorage에 저장 하면 CSRF에는 안전합니다. 브라우저가 자동으로 보내지 않고, JavaScript가 명시적으로 Authorization 헤더에 넣어야 하기 때문입니다. 하지만 XSS에 취약합니다. XSS가 발생하면 localStorage.getItem("token")으로 토큰을 탈취할 수 있습니다.

JWT를 HttpOnly 쿠키에 저장 하면 XSS로부터 토큰 직접 탈취는 막을 수 있습니다. 하지만 쿠키이므로 자동 전송되어 CSRF에 다시 취약해집니다. 결국 SameSite와 CSRF 토큰이 필요합니다.

어떤 방식이 "정답"인지는 없습니다. 중요한 것은 각 방식의 공격 표면을 이해하고, 그에 맞는 방어를 적용하는 것입니다.

// JWT를 Authorization 헤더로 전송, CSRF 안전
fetch("/api/transfer", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to: "friend", amount: 50000 }),
});

이 방식에서 token은 JavaScript 변수이므로 브라우저가 자동으로 포함하지 않습니다. 공격자의 evil.com에서 이 코드를 실행해도, token 변수에 접근할 수 없으므로 인증된 요청을 만들 수 없습니다. 물론 XSS가 발생하면 같은 출처에서 실행되는 코드이므로 이야기가 달라집니다.

다음 단계

CSRF는 브라우저가 쿠키를 자동 전송하는 특성을 악용하는 공격입니다. 코드를 주입하는 XSS와 달리, 사용자의 인증된 세션을 도용하여 의도하지 않은 요청을 보내게 만듭니다. SameSite 쿠키가 기본값이 된 현재, CSRF의 위험은 크게 줄었지만 완전히 사라진 것은 아닙니다. CSRF 토큰과 인증 방식 선택을 함께 고려해야 합니다.

다음 글에서는 Content Security Policy(CSP)와 보안 헤더 를 다룹니다. CSP는 XSS의 최후 방어선이고, 다양한 보안 헤더들이 클릭재킹, MIME 스니핑, 프로토콜 다운그레이드 같은 추가 공격을 차단합니다. 브라우저에게 "이 사이트는 이렇게 동작해야 한다"고 명시적으로 알려주는 방법을 살펴봅니다.