
쿠키로 로그인하는 서비스의 인터셉터를 보면 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 예시는 요즘 브라우저에서 그대로는 잘 안 통한다. 그럼 토큰을 빼도 되나. 그렇지는 않다. 브라우저 기본값만 믿기에는 남는 구멍이 넷이다.
- 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 토큰 전송하기
받아서, 두고, 붙이고, 갱신한다. 방식이 무엇이든 프론트가 하는 일은 이 넷이 전부다.
받는다. 로그인 응답이나 별도 엔드포인트(/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. CSRF 토큰 구현 시 주의점
제일 잡기 어려운 이슈는 CORS 쪽이다. 코드는 다 맞는데 요청이 서버에 도착조차 안 한다. 서버의 Access-Control-Allow-Headers 에 X-CSRF-Token 을 넣지 않으면 프리플라이트에서 걸린다. 브라우저 콘솔에는 CORS 에러만 뜨고 CSRF 는 언급도 안 되니 원인이 토큰 쪽이라는 게 안 보인다.
나머지 넷은 이렇다.
- GET 에 토큰을 붙인다 — GET 은 상태를 바꾸지 않는 게 원칙이니 토큰이 필요 없다. 붙이기 시작하면 캐시나 링크 공유에서 값이 새어 나가기 쉽다.
localStorage에 보관한다 — 위에서 말한 그대로다. 메모리나 서버가 관리하는 쿠키를 쓴다.- 로그인 직후 바로 다음 요청을 쏜다 — 응답 처리 → 토큰 저장 → 다음 요청 순서가 보장되지 않으면 첫 요청만 403 이 난다.
- 탭이 여러 개일 때 동기화가 없다 — 한 탭에서 토큰이 갱신되면 다른 탭은 옛날 값을 들고 있다.
BroadcastChannel이나storage이벤트로 맞춰 준다.
7. JWT 전달 방식과 CSRF
없어지는 것도 있고 새로 생기는 것도 있다. 토큰을 Authorization 헤더에 실어 보내는 방식은 브라우저가 자동으로 붙여 주지 않으니까, CSRF 자체가 성립하기 어렵다. 대신 그 토큰을 어디에 보관하느냐가 새 문제로 온다.
| 측면 | CSRF 토큰 + 쿠키 세션 | JWT (Bearer) |
|---|---|---|
| CSRF 위험 | 토큰 미부착 시 위험 | 거의 없음 (쿠키 미사용 시) |
| XSS 위험 | HttpOnly 쿠키면 안전 | localStorage 보관 시 노출 |
| 구현 복잡도 | FE-BE 양쪽 토큰 동기화 필요 | 헤더만 관리 |
| 로그아웃 즉시성 | 서버 세션 무효화로 즉시 | 토큰 만료 전까지 유효 (블랙리스트 필요) |
표 3. 쿠키 세션과 JWT 를 네 측면에서 비교
XSS 칸은 한 가지를 덧붙여야 정확하다. HttpOnly 쿠키는 세션 값을 읽어 가는 것을 막을 뿐이다. 스크립트가 이미 우리 페이지 안에서 돌고 있다면, 그 스크립트는 우리 코드와 똑같이 토큰을 읽고 요청을 보낼 수 있다. CSRF 방어가 XSS 방어를 대신해 주지는 못한다. 이 둘은 따로 막아야 한다.
8. CSRF 토큰으로 확인하는 것
CSRF 토큰이 하는 일은 딱 하나, 브라우저가 자동으로 못 채우는 값을 하나 요구해서 "이 요청을 우리 화면이 만들었는가" 를 서버가 확인할 수 있게 만드는 것이다.
인터셉터에서 토큰을 추가하면 서버는 쿠키와 별도로 애플리케이션 코드가 보낸 값을 확인할 수 있다.
참고