쿠키가 자동으로 붙는데 CSRF 토큰은 왜 필요할까

X Facebook

Written by

in

로그인은 쿠키로 처리하는데, 인터셉터는 POST 요청마다 X-CSRF-Token 헤더를 하나 더 붙인다. 쿠키가 자동으로 전송되는데 별도의 토큰은 왜 필요할까?

CSRF는 공격자가 쿠키를 훔쳐야만 가능한 공격이 아니다. 쿠키 값을 몰라도 사용자의 브라우저가 그 쿠키를 붙여 요청하도록 유도할 수 있다. 쿠키와 CSRF 토큰을 각각 누가 채우는지 살펴보고, 그 차이가 서버의 검증과 프론트엔드 코드에 어떻게 반영되는지 알아본다.

1. 훔치는 게 아니라 브라우저가 보내 준다

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

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

HTML 예시: 크기가 0인 이미지 태그의 주소가 쇼핑몰의 장바구니 비우기 주소(shop.example.com/cart/clear)를 가리킨다.

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

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

HTML 예시: 쇼핑몰 이메일 변경 주소(shop.example.com/account/email)로 POST하는 폼에 공격자 이메일을 숨은 입력값으로 넣고, 페이지가 열리자마자 스크립트가 그 폼을 자동으로 제출한다.

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

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

2. 그럼 쿠키만으로는 왜 못 막을까

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

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

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

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

3. SameSite가 생겼는데 토큰이 아직도 필요할까

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

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

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

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

  • GET으로 상태를 바꾸는 API가 있으면 Lax는 못 막는다. 최상위 이동 GET에는 쿠키가 붙으니까, <a href> 나 리다이렉트 한 번이면 그대로 통과한다. 위의 /cart/clear 같은 API가 여기 해당한다.
  • SameSite의 “같은 사이트”는 도메인 하나가 아니라 등록 도메인 단위다. a.example.com과 b.example.com은 같은 사이트로 친다. 서브도메인 하나에 공격자가 스크립트를 올릴 수 있으면 SameSite는 아무것도 막지 못한다.
  • 프론트와 API의 도메인이 다르면 SameSite=None을 쓸 수밖에 없다. 그 순간 이 방어는 0이 된다. 이때는 토큰이 유일한 방어다.
  • 기본값을 믿을 수 없는 경우가 남는다. 오래된 브라우저는 SameSite를 아예 모른다. 그리고 크롬에는 갓 발급된 쿠키에 한해 짧은 시간 동안 다른 사이트발 POST를 허용해 주는 예외가 있다고 알려져 있는데, 지금 버전에서도 그대로인지는 확인하지 못했다.

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

4. 서버가 토큰을 어디에 두느냐로 프론트 일이 달라진다

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

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

표 2. 두 토큰 방식의 차이

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

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

5. 붙이는 자리는 결국 인터셉터 한 곳이었다

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

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

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

  • 메모리(JS 변수나 스토어) — 새로고침하면 다시 받아야 한다. 대신 JS만 접근할 수 있어서 안전하다.
  • <meta name="csrf-token"> — 서버가 HTML을 그려 주는 구조에 맞다. document.querySelector로 읽는다.
  • 쿠키 (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. 자주 걸리는 자리

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

나머지 넷은 이렇다.

  • GET에 토큰을 붙인다 — GET은 상태를 바꾸지 않는 게 원칙이니 토큰이 필요 없다. 붙이기 시작하면 캐시나 링크 공유에서 값이 새어 나가기 쉽다.
  • localStorage에 보관한다 — 위에서 말한 그대로다. 메모리나 서버가 관리하는 쿠키를 쓴다.
  • 로그인 직후 바로 다음 요청을 쏜다 — 응답 처리 → 토큰 저장 → 다음 요청 순서가 보장되지 않으면 첫 요청만 403이 난다.
  • 탭이 여러 개일 때 동기화가 없다 — 한 탭에서 토큰이 갱신되면 다른 탭은 옛날 값을 들고 있다. BroadcastChannel이나 storage이벤트로 맞춰 준다.

7. 그럼 JWT로 가면 이 일이 없어질까

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

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

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

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

8. 그래서 헤더 한 줄이 무슨 뜻이었나

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

이걸 알고 나면 인터셉터 코드가 달리 보인다. 헤더 한 줄을 넣는 그 코드가 사실은 “브라우저가 아니라 우리 코드가 이 요청을 만들었다”는 서명이다.


참고

  • https://blog.logto.io/ko/csrf

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다