aster®

쿠키 인증에 CSRF 토큰이 필요한 이유

2026-04 · 인증

가로로 긴 봉투 하나에 빈 칸 두 개가 나란히 있다. 왼쪽 칸은 화면 밖에서 뻗어 온 선이 자동으로 채웠고, 오른쪽 위에서 날아온 화살표는 두 번째 빈 칸 앞에서 멈춰 서 있다.

쿠키로 로그인하는 서비스의 인터셉터를 보면 POST 요청마다 X-CSRF-Token 이라는 헤더를 하나 더 붙인다. 자동으로 붙는 쿠키 외에 CSRF 토큰도 보내는 것이다. CSRF 를 "공격자가 내 쿠키를 훔쳐 가는 공격" 으로 알고 있으면 이 코드가 끝까지 안 읽힌다. 훔쳐 갔으면 헤더 하나가 소용없을 테니까.

CSRF에서는 공격자가 쿠키 값을 한 글자도 몰라도 피해자의 브라우저를 통해 쿠키가 붙은 요청을 보낼 수 있다.

쿠키는 누가 채우고 토큰은 누가 채우는지, 그리고 그 차이 때문에 프론트엔드가 실제로 무슨 코드를 쓰게 되는지를 본다.

1. 쿠키 자동 첨부를 이용하는 CSRF 공격

먼저 앞선 이야기를 한 줄로 깔아 두자. 쿠키 기반 로그인은 서버가 로그인 성공 시 세션 ID 를 쿠키로 내려보내고, 그 뒤로는 브라우저가 그 쿠키를 알아서 붙여 주는 방식이다. 프론트엔드 코드는 로그인 이후에 아무것도 안 한다. 이 "알아서 붙여 준다" 가 편한 만큼 위험하다는 게 오늘의 이야기다.

장면을 하나 그려 보자. 쇼핑몰 shop.example.com 에 로그인해 두고 탭을 닫지 않았다. 다른 탭에서 커뮤니티 글 하나를 연다. 그 글 안에 이런 태그가 하나 숨어 있다.

<img src="https://shop.example.com/cart/clear" width="0" height="0" />

브라우저는 이미지를 받으려고 shop.example.com 으로 GET 을 보낸다. 그리고 그 주소로 저장해 둔 쿠키를 같이 붙인다. 이 태그를 품고 있는 페이지가 커뮤니티든 어디든 상관없다. 쿠키는 요청을 만든 쪽이 아니라 요청을 받는 쪽 주소를 보고 붙기 때문이다.

이미지 태그는 GET 만 보내지만, 값을 바꾸는 POST 도 마찬가지다.

<form action="https://shop.example.com/account/email" method="POST">
  <input type="hidden" name="email" value="attacker@evil.example" />
</form>
<script>
  document.forms[0].submit();
</script>

form 전송은 CORS 프리플라이트를 타지 않는다. 물론 응답을 자바스크립트로 읽는 건 막힌다. 그런데 공격자에게 응답은 필요 없다. 서버가 이메일을 바꾸기만 하면 그걸로 끝이다.

정리하면 이렇다. 공격자는 쿠키 값을 모르고 볼 수도 없다. 대신 사용자의 브라우저에게 요청을 시켰고, 쿠키는 브라우저가 붙였다. 서버 쪽에서 보면 이 요청은 사용자가 화면에서 버튼을 누른 것과 구별되지 않는다. 이런 걸 CSRF, Cross-Site Request Forgery 라고 한다. 다른 사이트에서 요청을 위조한다는 뜻이고, 위조되는 건 쿠키가 아니라 요청이다.

2. 쿠키 인증만으로 요청 출처를 확인할 수 없는 이유

서버가 "쿠키가 붙어 있으니 로그인한 사람이 맞다" 까지는 판단할 수 있다. 그런데 "이 요청을 우리 화면이 만들었다" 는 판단은 못 한다. 판단할 재료가 요청 안에 없어서다.

브라우저가 쿠키를 자동으로 붙이는 건 버그가 아니라 설계다. 매 요청마다 아이디와 비밀번호를 다시 보내지 않아도 되게 하려고 그렇게 만들었다. 편의를 위해 만든 성질이 그대로 공격 경로가 된 셈이라, 쿠키 쪽에서 이걸 막을 방법은 원래 없었다.

그래서 방향을 뒤집는다. 브라우저가 자동으로 못 넣는 값을 하나 더 요구하는 것이다. 서버가 발급한 임의의 값을 요청 헤더나 본문에 직접 넣게 하고, 그 값이 맞을 때만 처리한다. 공격자 페이지는 이 값을 채울 수 없다. 다른 출처의 페이지 내용도, 다른 출처의 쿠키도 읽지 못하게 브라우저가 막고 있기 때문이다.

쿠키는 브라우저가 채우고, 토큰은 우리 코드가 채운다. 공격자는 브라우저를 시킬 수 있어도 우리 코드가 되지는 못한다.

3. SameSite와 CSRF 토큰

여기까지 읽고 나면 한 번 멈추게 되는 자리가 있다. 쿠키에는 SameSite 라는 속성이 있고, 이건 "다른 사이트에서 시작된 요청에는 이 쿠키를 붙이지 마라" 는 표시다. 값은 셋이다.

값언제 쿠키가 붙나
Strict다른 사이트에서 시작된 요청에는 붙지 않는다. 링크를 눌러 들어와도 안 붙어서 로그인이 풀린 것처럼 보인다
Lax주소가 바뀌는 최상위 이동(주로 링크 클릭 GET)에는 붙는다. img·iframe·fetch 같은 하위 요청과 다른 사이트에서 온 POST 에는 안 붙는다
None언제나 붙는다. Secure 가 함께 필요하다

표 1. SameSite 세 값이 쿠키를 붙이는 조건

크롬은 SameSite 를 안 적은 쿠키를 Lax 로 취급한다. 그러니 위에서 본 form POST 예시는 요즘 브라우저에서 그대로는 잘 안 통한다. 그럼 토큰을 빼도 되나. 그렇지는 않다. 브라우저 기본값만 믿기에는 남는 구멍이 넷이다.

그래서 이건 둘 중 하나를 고르는 문제가 아니다. SameSite 는 켜고, 토큰도 붙인다. SameSite 는 대부분의 흔한 공격을 브라우저 단에서 미리 걸러 주는 장치이고, 토큰은 걸러지지 않은 나머지를 서버가 직접 확인하는 장치다.

4. 토큰 저장 방식에 따른 구현 차이

토큰 방식은 크게 둘로 나뉜다. 이름이 길어서 그렇지 차이는 하나다. 서버가 정답을 기억하느냐, 안 기억하느냐.

Synchronizer TokenDouble Submit Cookie
서버가 정답을 어디 두나세션에 같이 저장한다저장하지 않는다. 쿠키로 내려보내고 잊는다
무엇과 무엇을 비교하나세션에 저장된 값 ↔ 요청에 담긴 값쿠키에 담긴 값 ↔ 요청에 담긴 값
프론트가 값을 어디서 받나로그인 응답 본문, 별도 엔드포인트(/csrf-token), 또는 <meta name="csrf-token">쿠키. JS 가 읽어야 하니 이 쿠키만은 HttpOnly 가 아니다
프론트가 하는 일받아서 메모리에 두고, 요청 헤더에 넣는다쿠키를 읽어서 같은 값을 헤더에 복사한다

표 2. 두 토큰 방식의 차이

Double Submit 은 서버가 세션을 안 들고 있어도 되니까 구현이 가볍다. 대신 약점이 있다. 서버가 정답을 기억하지 않으니, 공격자가 그 도메인에 쿠키를 심을 수만 있으면 자기가 아는 값으로 덮어쓰고 헤더에도 같은 값을 넣어 통과시킬 수 있다. 서브도메인 하나를 잡으면 부모 도메인 쿠키를 쓸 수 있어서 이 경로가 열린다. 그래서 요즘 권고는 토큰에 서명을 붙이거나 __Host- 접두사 쿠키를 쓰라는 쪽이다. __Host- 는 Domain 속성을 못 쓰게 막고 Secure 와 Path=/ 를 강제하는 쿠키 이름 접두사다.

프론트 입장에서 보면 둘의 차이는 결국 "값을 어디서 받아 오느냐" 한 줄이다. 받은 다음에 하는 일은 똑같다.

5. 인터셉터에서 CSRF 토큰 전송하기

받아서, 두고, 붙이고, 갱신한다. 방식이 무엇이든 프론트가 하는 일은 이 넷이 전부다.

받는다. 로그인 응답이나 별도 엔드포인트(/csrf-token)에서 토큰을 받는다. Double Submit 이면 서버가 내려 준 쿠키를 읽는다.

둔다. 보관 위치는 셋 중 하나다.

localStorage 에는 두지 않는다. XSS 가 한 번 나면 저장해 둔 값이 그대로 읽히기 때문이다.

붙인다. POST·PUT·PATCH·DELETE 처럼 상태를 바꾸는 메서드에만 붙인다. 표준 헤더 이름은 X-CSRF-Token 이고, form-encoded 요청이면 hidden input 으로 넣는다. 요청마다 손으로 넣지 말고 HTTP 클라이언트 인터셉터에 한 번 심어 두는 게 보통이다.

// axios 인터셉터 예시
axios.interceptors.request.use((config) => {
  const method = config.method?.toLowerCase();
  if (method && ["post", "put", "patch", "delete"].includes(method)) {
    config.headers["X-CSRF-Token"] = getCsrfToken();
  }
  return config;
});

갱신한다. 1회성 토큰이면 응답에서 새 토큰을 받아 갈아 끼운다. 만료되면 보통 403 에 특정 에러코드가 같이 오는데, 그때 토큰을 다시 받아서 요청을 자동으로 재시도한다. 로그아웃할 때는 메모리·메타·쿠키에 있는 토큰을 전부 폐기한다.

6. CSRF 토큰 구현 시 주의점

제일 잡기 어려운 이슈는 CORS 쪽이다. 코드는 다 맞는데 요청이 서버에 도착조차 안 한다. 서버의 Access-Control-Allow-Headers 에 X-CSRF-Token 을 넣지 않으면 프리플라이트에서 걸린다. 브라우저 콘솔에는 CORS 에러만 뜨고 CSRF 는 언급도 안 되니 원인이 토큰 쪽이라는 게 안 보인다.

나머지 넷은 이렇다.

7. JWT 전달 방식과 CSRF

없어지는 것도 있고 새로 생기는 것도 있다. 토큰을 Authorization 헤더에 실어 보내는 방식은 브라우저가 자동으로 붙여 주지 않으니까, CSRF 자체가 성립하기 어렵다. 대신 그 토큰을 어디에 보관하느냐가 새 문제로 온다.

측면CSRF 토큰 + 쿠키 세션JWT (Bearer)
CSRF 위험토큰 미부착 시 위험거의 없음 (쿠키 미사용 시)
XSS 위험HttpOnly 쿠키면 안전localStorage 보관 시 노출
구현 복잡도FE-BE 양쪽 토큰 동기화 필요헤더만 관리
로그아웃 즉시성서버 세션 무효화로 즉시토큰 만료 전까지 유효 (블랙리스트 필요)

표 3. 쿠키 세션과 JWT 를 네 측면에서 비교

XSS 칸은 한 가지를 덧붙여야 정확하다. HttpOnly 쿠키는 세션 값을 읽어 가는 것을 막을 뿐이다. 스크립트가 이미 우리 페이지 안에서 돌고 있다면, 그 스크립트는 우리 코드와 똑같이 토큰을 읽고 요청을 보낼 수 있다. CSRF 방어가 XSS 방어를 대신해 주지는 못한다. 이 둘은 따로 막아야 한다.

8. CSRF 토큰으로 확인하는 것

CSRF 토큰이 하는 일은 딱 하나, 브라우저가 자동으로 못 채우는 값을 하나 요구해서 "이 요청을 우리 화면이 만들었는가" 를 서버가 확인할 수 있게 만드는 것이다.

인터셉터에서 토큰을 추가하면 서버는 쿠키와 별도로 애플리케이션 코드가 보낸 값을 확인할 수 있다.


참고