웹/클라우드/인프라로 돌아가기
웹/클라우드/인프라신윤섭·2026년 8월 24일

남의 페이지가 내 서버에 로그인된 요청을 보낸다: SameSite와 CSRF

공유

에이전트가 내놓는 한 줄

Claude Code한테 시킨다. "로그인 붙여줘. 세션은 쿠키로." Node로 짜면 이런 줄이 나온다.

res.cookie('session', sessionId);

Python으로 짜면 이렇게 나온다.

response.set_cookie("session", session_id)

둘 다 잘 된다. 로그인하면 쿠키가 심기고, 새로고침해도 유지되고, 로그아웃하면 사라진다. 배포해도 똑같다. 테스트를 짜도 통과한다.

그런데 이 두 줄은 브라우저에 도착했을 때 서로 다른 쿠키가 된다. 한쪽은 최소한의 방어선이라도 갖고 있고, 다른 한쪽은 사용자가 무슨 브라우저를 쓰느냐에 따라 방어선이 있기도 하고 없기도 하다. 코드만 봐서는 구분이 안 된다. 화면에도 안 나온다. 차이가 드러나는 건 나중에, 누가 이용자에게 링크 하나를 보낸 뒤다.

쿠키는 누가 시킨 요청인지 묻지 않는다

쿠키가 뭘 하는 물건인지부터 정확히 해두자. 서버가 Set-Cookie로 값을 내려주면 브라우저는 그걸 도메인별로 보관한다. 그리고 그 도메인으로 요청이 나갈 때마다 알아서 꺼내 붙인다.

여기서 "요청이 나갈 때마다"라는 말이 생각보다 넓다. 주소창에 직접 쳐서 들어가도 붙고, 내 사이트 안에서 fetch를 날려도 붙고, 전혀 다른 사이트에 있는 폼이 내 서버로 제출돼도 붙는다. 브라우저가 보는 건 "이 요청이 어디로 가는가" 하나다. "이 요청을 누가 시켰는가"는 보지 않는다.

건물 출입증에 비유하면 이렇다. 출입증을 목에 걸고 있으면 문 앞에 섰을 때 문이 열린다. 문은 내가 왜 여기 왔는지, 누가 나를 여기로 데려왔는지 묻지 않는다. 내가 제 발로 왔든 누가 등을 떠밀어 세워놨든 결과가 같다.

그래서 이런 게 성립한다. 바이브 코딩으로 만든 내부 대시보드가 있다고 하자. 팀원들이 로그인해서 쓰고, 계정 설정 페이지에서 알림 받을 이메일 주소를 바꿀 수 있다. 공격자는 자기 사이트에 이걸 심어둔다.

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

로그인 상태인 팀원이 이 페이지를 열기만 하면 브라우저가 mydashboard.com으로 POST를 보내면서 세션 쿠키를 붙여준다. 내 서버 입장에서는 정상 로그인한 사용자가 이메일을 바꿔달라고 한 것과 구별이 안 된다. 헤더도 정상이고 쿠키도 진짜다. 이게 CSRF다. 남의 브라우저를 빌려서 내 서버에 요청을 보내는 공격이다.

주의할 대목이 있다. 공격자는 응답을 읽지 못한다. 읽을 필요도 없다. 알림 이메일 주소가 바뀌면 다음번 비밀번호 재설정 메일이 공격자에게 가고, 그다음은 계정 전체다. 요청이 도착한 것만으로 끝난다.

로그인 상태의 브라우저가 공격 사이트의 요청에 세션 쿠키를 붙여 보내는 흐름
로그인 상태의 브라우저가 공격 사이트의 요청에 세션 쿠키를 붙여 보내는 흐름

CORS가 막아주는 줄 알았다면

여기서 대부분 걸린다. "다른 도메인에서 내 API를 부르면 CORS 에러 나잖아요?"

CORS는 그 일을 하는 물건이 아니다. CORS가 통제하는 건 자바스크립트가 응답을 읽을 수 있느냐다. 요청이 서버까지 가는 것 자체를 막지 않는다. 브라우저는 요청을 보내고, 서버는 처리하고, 그 뒤에 브라우저가 "이 응답은 너한테 안 보여줄게"라며 결과만 가린다. 데이터베이스에서는 이미 이메일이 바뀐 뒤다.

게다가 위의 HTML은 fetch가 아니라 그냥 <form>이다. 폼 제출은 브라우저가 웹 초창기부터 허용해온 동작이라 사전 확인 요청(preflight) 대상도 아니다. CORS 설정을 아무리 조여도 폼 제출은 그냥 나간다.

CORS는 "내 데이터를 남이 읽어가는 것"을 막고, CSRF 방어는 "남이 내 사용자를 시켜 뭔가 쓰게 하는 것"을 막는다. 방향이 반대다. 하나를 했다고 다른 하나가 되지 않는다.

SameSite는 쿠키에 조건을 다는 일이다

그래서 쿠키 쪽에 조건을 붙이는 방법이 나왔다. SameSite 속성이다. "이 요청을 어느 사이트가 시켰는지 보고, 조건에 맞을 때만 쿠키를 붙여라"라고 브라우저에 지시하는 값이고, 값은 셋이다.

Strict가 가장 빡빡하다. MDN 설명 그대로, 쿠키를 심은 그 사이트에서 시작된 요청에만 붙인다. 다른 사이트에서 넘어오면 링크를 눌러 들어온 경우까지 쿠키가 빠진다. 안전하지만 대가가 있다. 카카오톡으로 받은 링크를 눌러 들어가면 로그아웃 상태로 보이고, 새로고침해야 로그인 상태가 된다.

Lax는 그 대가를 덜어낸 값이다. MDN은 두 조건을 모두 만족하는 크로스 사이트 요청에만 쿠키를 허용한다고 적어뒀다. 하나는 최상위 내비게이션, 즉 주소창의 URL이 바뀌는 이동이어야 한다는 것. 다른 하나는 안전한 메서드여야 한다는 것이고, 여기서 POST와 PUT과 DELETE는 명시적으로 빠진다. 링크 클릭은 통과하고 백그라운드 폼 제출은 막힌다는 뜻이다. 위의 공격 HTML이 딱 이 지점에서 걸린다.

None은 조건을 걸지 않는다. 크로스 사이트든 뭐든 다 붙인다. 대신 Secure를 같이 달아야 하고, 안 달면 브라우저가 쿠키 자체를 버린다. 결제 위젯처럼 남의 사이트 안에 임베드돼야 하는 서비스에 필요한 값이다.

여기서 하나 짚어둘 게 있다. SameSite는 원래 쿠키 표준인 RFC 6265에 없던 속성이다. 후속 규격인 RFC 6265bis에 들어갔는데, 2025년 12월에 나온 22번째 초안이 아직 RFC 편집자 대기열에 있다. 브라우저가 먼저 만들어 쓰고 표준이 뒤따라가는 중이라는 뜻이고, 이 순서가 다음 이야기의 원인이다. 명세가 확정되기 전에 각자 구현했으니 각자 다르게 굴러간다.

안 적으면 브라우저가 대신 정한다

이제 처음 두 줄로 돌아가자. 둘 다 SameSite가 안 보인다. 그럼 어떻게 되나.

두 단계로 나뉜다. 먼저 프레임워크가 채우고, 프레임워크도 안 채우면 브라우저가 채운다.

Python 쪽은 채운다. FastAPI가 내부적으로 쓰는 Starlette의 set_cookie 기본값이 samesite="lax"다. 그래서 저 줄은 실제로 Lax 쿠키를 심고, 위 공격은 막힌다. 운이 좋았다. 다만 같은 시그니처의 secure=False, httponly=False도 같이 딸려온다. 세션 쿠키가 HTTP로도 나가고 자바스크립트로도 읽힌다는 뜻이라, 이 줄이 안전한 건 딱 CSRF 한 항목에 대해서다.

Node 쪽은 안 채운다. Express res.cookie() 문서에는 sameSite 옵션의 기본값이 적혀 있지 않고, 지정하지 않은 옵션은 RFC 6265를 따른다고만 되어 있다. 그런데 RFC 6265에는 SameSite가 없다. 결국 속성이 아예 안 붙은 Set-Cookie가 나가고, 그때부터는 브라우저가 알아서 정한다.

여기서 갈린다. 브라우저마다 답이 다르다.

Chromium 계열은 2020년 Chrome 80부터 SameSite 속성이 없는 쿠키를 Lax로 취급한다. Chromium의 SameSite FAQ에 그대로 적혀 있다. Chrome과 Edge를 비롯해 Chromium을 쓰는 브라우저 대부분이 여기 해당한다.

Firefox는 다르다. 같은 걸 하려는 시도가 있었고 Firefox 96에서 한 번 켜졌다가, 깨지는 사이트가 너무 많아 되돌렸다. 관련 버그(1617609)는 WONTFIX로 닫혔고, 담당자는 SameSite를 명시하지 않은 쿠키의 기본값이 여전히 None이며 다시 시도할 계획도 없다고 적어뒀다.

Firefox가 쿠키를 사이트별로 격리한다는 이야기를 들어봤다면 그게 대신 막아주지 않느냐고 물을 만하다. 막아주지 않는다. 그 격리는 남의 사이트 안에 끼어 들어간 서드파티 맥락의 쿠키를 다른 통에 담는 장치다. 그런데 위의 공격 폼은 브라우저를 통째로 mydashboard.com으로 보내는 최상위 이동이다. 도착한 순간 그건 서드파티가 아니라 그냥 내 사이트를 방문한 것이고, 원래 쓰던 그 쿠키 통이 열린다. 추적을 막는 장치와 CSRF를 막는 장치는 겨냥하는 지점이 다르다.

Safari도 서드파티 쿠키를 광범위하게 차단하지만, 그 근거 역시 SameSite 기본값이 아니라 추적 방지 정책이다.

정리하면 이렇다. 속성 한 글자를 안 적었을 뿐인데, 같은 서버가 내려준 같은 쿠키가 Chrome에서는 Lax로 동작하고 Firefox에서는 조건 없이 따라붙는다. 내 앱의 CSRF 방어 여부를 사용자가 고른 브라우저가 정하는 셈이다. 그리고 개발자는 대개 Chrome으로 테스트한다.

SameSite 값별 동작과 브라우저 기본값 차이
SameSite 값별 동작과 브라우저 기본값 차이

Lax를 적어도 남는 구멍 둘

Chrome을 쓰는 사용자만 있다고 쳐도, 또는 내가 SameSite=Lax를 직접 적었다고 쳐도 그게 완전한 방어는 아니다. 구멍이 둘 남는데 둘 다 설계상 의도된 것이다.

첫째, GET은 통과한다. Lax는 최상위 이동이면서 안전한 메서드인 요청에 쿠키를 붙인다고 했다. 그러니 상태를 바꾸는 기능이 GET에 걸려 있으면 링크 한 줄로 뚫린다. 공격자가 할 일은 브라우저를 그 주소로 보내는 것뿐이다.

<script>location.href = "https://mydashboard.com/api/delete-account?confirm=yes"</script>

주소창이 바뀌는 이동이고 메서드는 GET이니 Lax 조건을 둘 다 만족한다. 쿠키가 붙어서 나간다. 흔히 예시로 드는 <img src="...">는 여기서 통하지 않는다는 점도 같이 알아둘 만하다. 이미지 로딩은 최상위 이동이 아니라 페이지 안에서 부품을 하나 불러오는 요청이라 Lax 조건에서 탈락한다. 다만 SameSite가 없는 쿠키를 Firefox가 처리할 때는 조건 자체가 없으니 그때는 <img> 한 줄로도 나간다.

에이전트가 상태를 바꾸는 GET 엔드포인트를 만드는 경우는 생각보다 흔하다. 브라우저에서 주소만 쳐도 테스트되니 개발 중에 편하고, 프롬프트에 메서드를 지정하지 않으면 GET으로 나오기 쉽다. "GET은 읽기만 한다"는 지켜야 성립하는 약속이지 저절로 지켜지는 성질이 아니다.

둘째, Chromium에 2분짜리 예외가 있다. MDN이 각주로 달아둔 내용인데, Lax가 기본값으로 적용될 때는 조금 더 느슨한 버전이 쓰인다. 쿠키가 설정된 지 2분이 안 됐으면 POST 요청에도 붙는다. Chromium FAQ는 이걸 순전히 임시 조치이며 나중에 제거될 것이라고 못 박아뒀다.

조건을 정확히 볼 필요가 있다. 이 예외는 브라우저가 알아서 Lax를 매긴 쿠키에만 적용된다. 서버가 SameSite=Lax라고 직접 적어 보낸 쿠키에는 적용되지 않는다. 같은 Lax인데 출처가 어디냐에 따라 동작이 갈리는 것이다. 그래서 안 적어서 Lax가 된 쿠키는 로그인 직후 2분 동안 크로스 사이트 POST에 열려 있고, 직접 적은 Lax는 그 2분이 없다. 뒤에서 "값을 직접 적어라"라고 말하는 이유가 여기에 있다. 브라우저 기본값에 맡기는 것과 같은 값을 손으로 적는 것은 결과가 다르다.

같은 사이트 안에서는 아무 일도 안 한다

셋째 구멍은 이름에 이미 들어 있다. SameSite는 사이트 단위로 판단한다. 그리고 웹에서 말하는 "사이트"가 생각보다 넓다.

MDN 정의로는 공개 접미사 목록에 있는 항목에 그 앞 한 조각을 붙인 것, 그러니까 등록 가능 도메인이 사이트다. mozilla.org가 사이트고 support.mozilla.orgdeveloper.mozilla.org는 같은 사이트다. 포트는 아예 안 본다. https://example.com:8080https://example.com이 같은 사이트다. 스킴은 경우에 따라 다른데, SameSite 쿠키에는 스킴까지 따지는 더 엄격한 기준이 적용된다.

여기서 나오는 결론을 OWASP가 정확히 적어뒀다. app.example.com에 심은 쿠키는 SameSite 값이 무엇이든 anything.example.com에서 온 요청이면 여전히 같은 사이트로 취급된다는 것이다. Strict로 잠가도 마찬가지다.

문제가 되는 상황은 이렇다. 관리하지 않는 서브도메인에 남이 올린 콘텐츠가 있다면, 거기서 나가는 요청은 브라우저 눈에 내 사이트가 보낸 요청이다. 그 서브도메인에 XSS가 하나만 있어도 그렇다. OWASP는 방치된 서브도메인을 누가 가로챈 경우까지 짚는다. DNS 레코드는 남아 있는데 가리키던 서비스는 사라진 주소를 공격자가 자기 것으로 등록하면, 거기서 보낸 요청에 내 SameSite 쿠키가 그대로 따라붙는다. 예전에 만들어 쓰다 만 demo.myapp.com 같은 게 남아 있다면 지금 확인해볼 만하다.

그래서 OWASP의 결론은 단호하다. SameSite는 여러 겹 중 한 겹으로 쓸 만한 장치이지 제대로 된 CSRF 방어를 대체하지 못한다는 것이다.

Lax를 쓰고도 뚫린 AI 도구

로컬에 LLM을 띄워 쓰는 사람이라면 한 번쯤 설치해봤을 Open WebUI에 이 문제가 있었다. CVE-2024-7806이다.

NVD 설명은 짧다. 0.3.8 이하 버전은 인증에 SameSite가 lax인 쿠키를 쓰면서 CSRF 토큰이 없었다. 그래서 공격자가 만든 HTML을 피해자가 열면, 기존 파이프라인의 파이썬 코드를 고쳐 피해자 권한으로 실행시킬 수 있었다. 관리자가 아닌 일반 사용자 계정으로도 원격 코드 실행까지 갔다. NIST가 매긴 점수는 8.8, 높음이다. 0.3.33에서 고쳐졌다.

이 사례를 남 얘기로 두기 어려운 이유가 둘 있다.

하나. 이건 바이브 코더가 자기 노트북에 직접 띄워 쓰는 종류의 도구다. 로컬에 띄웠으니 안전하다고 여기기 쉬운데, 공격은 서버를 뚫고 들어오는 게 아니라 그 노트북의 브라우저를 거쳐 들어온다. 웹페이지 하나 여는 것으로 시작한다.

둘. 이 도구는 SameSite를 안 쓴 게 아니다. Lax를 쓰고 있었는데도 뚫렸다. 앞에서 본 구멍 중 어느 것을 밟았는지는 공개된 설명만으로 확정하기 어렵다. GET 엔드포인트였는지, 로그인 직후 2분 창이었는지, 다른 경로였는지는 원 보고서를 봐야 알 수 있다. 다만 확정할 수 없어도 남는 결론은 분명하다. Lax가 걸려 있다는 사실 하나로 CSRF 방어를 끝냈다고 보면 이렇게 된다. 여기에 CSRF 토큰이 없었다는 조건이 겹치면서 계정 탈취가 아니라 코드 실행까지 갔다.

에이전트는 CSRF를 요구받은 적이 없다

이 문제가 바이브 코딩에서 특히 잘 생기는 이유가 있다.

첫째, 에이전트는 프레임워크의 기본 시그니처를 따라간다. 그리고 그 기본값들이 하필 느슨하다. Starlette의 set_cookiesamesite="lax"까지는 채워주지만 secure=False, httponly=False가 기본이라고 문서에 적혀 있다. Express는 셋 다 안 채운다. 에이전트가 잘못 짠 게 아니라 기본값을 그대로 쓴 것인데, 결과가 취약하다.

둘째, 완료 조건이 로그인까지다. "로그인 붙여줘"라는 요청에서 성공은 로그인이 되는 것이다. 남의 사이트에서 날아온 폼 제출을 막는 일은 요구사항에 없었고, 없으면 안 짠다. 에이전트가 게으른 게 아니라 시킨 일을 한 것이다.

셋째, 실패가 조용하다. 이게 제일 나쁘다. CORS 문제였다면 최소한 콘솔에 빨간 줄이라도 뜬다. 인증서 문제였다면 브라우저가 경고 페이지를 띄운다. 여기는 아무 일도 안 일어난다. 에러도 없고, 경고도 없고, 테스트도 통과하고, 심지어 Chrome에서 직접 공격을 재현해보면 막히기까지 한다. Firefox를 켜보기 전까지는.

그러니 요청을 한 겹 더 얹어야 한다. "세션 쿠키는 HttpOnly, Secure, SameSite=Lax를 전부 명시해서 심고, 상태를 바꾸는 엔드포인트는 전부 POST로 만들고 CSRF 토큰 검증을 붙여줘." 이 정도까지 적어주면 대체로 맞게 나온다.

확인은 30초, 방어는 세 겹

이미 나와 있는 코드를 점검하는 건 오래 안 걸린다.

브라우저 개발자 도구를 열고 Application 탭의 Cookies로 가면 세션 쿠키 한 줄이 보인다. SameSite 칸이 비어 있으면 서버가 안 적어 보낸 것이고, 지금 이 글의 모든 이야기가 내 앱 이야기다. HttpOnly와 Secure 칸의 체크 표시도 같이 본다. 터미널을 선호한다면 curl -i로 로그인 요청을 보내고 Set-Cookie 줄을 직접 읽어도 된다.

고치는 건 세 겹이고, 손이 덜 가는 순서로 놓으면 이렇다.

한 줄이면 되는 것부터. 쿠키에 값을 명시한다. 브라우저 기본값에 맡기지 말고 SameSite=Lax를 직접 적고, HttpOnlySecure를 같이 단다. 이 한 줄이 막는 것은 배경에서 조용히 날아오는 크로스 사이트 폼 제출, 그리고 앞에서 본 2분 창이다. 외부 사이트에 임베드될 일이 없다면 Strict도 검토할 만한데, 링크 타고 들어온 사용자가 로그아웃돼 보이는 문제를 감수해야 한다.

미들웨어 하나면 되는 것. 요청의 출처를 확인한다. OWASP가 권하는 방식은 두 단계다. 요청이 어디서 왔는지를 Origin 헤더에서 읽고, 어디로 가는지를 서버가 아는 자기 주소에서 읽어서, 둘이 맞는지 본다. 상태를 바꾸는 엔드포인트에 이 검사를 공통으로 깔면 앞 겹이 못 막는 GET 통로와 2분 창까지 함께 닫힌다.

여기에 함정이 하나 있다. 이 검증이 실전에서 무력화되는 경로는 거의 항상 같다. Origin 헤더가 없는 요청을 통과시키는 것. 헤더가 없으면 비교할 대상이 없으니 이런 코드가 자연스럽게 나온다.

// 이렇게 짜면 헤더를 빼고 보내는 요청은 전부 통과한다
if (origin && origin !== ALLOWED_ORIGIN) return res.status(403).end();

에이전트한테 맡기면 십중팔구 이 모양이 된다. 없는 값을 거절하는 코드보다 있는 값만 검사하는 코드가 더 자연스러워 보이기 때문이다. 상태를 바꾸는 요청이라면 헤더가 없는 쪽도 거절하는 게 맞다.

설계를 손대야 하는 것. 토큰이다. OWASP가 꼽는 정석은 동기화 토큰 패턴이고, 이중 제출 쿠키 방식을 쓴다면 세션에 묶어 서명한 형태를 가장 안전한 구현으로 든다. 어느 쪽이든 핵심은 하나다. 브라우저가 자동으로 붙여주지 않는 값을 요청에 하나 요구하는 것. 앞의 두 겹이 "이 요청이 어디서 왔는가"를 따졌다면 이 겹은 "이 요청이 내 화면을 거쳐 나왔는가"를 따진다. 같은 사이트로 취급되는 서브도메인에서 날아온 요청까지 여기서 걸린다.

정리

쿠키는 요청이 어디로 가는지만 보고 따라붙는다. 누가 그 요청을 시켰는지는 보지 않는다. CSRF는 그 성질을 그대로 이용하고, CORS는 그걸 막는 물건이 아니다.

SameSite는 쿠키에 "누가 시킨 요청인지" 조건을 다는 장치다. 다만 조건을 안 적으면 브라우저가 대신 정하고, 그 답이 브라우저마다 갈린다. Chrome은 Lax를 매기고 Firefox는 아무 조건 없이 붙인다. Lax를 제대로 적어도 GET으로 상태를 바꾸는 엔드포인트와 같은 사이트로 취급되는 서브도메인은 여전히 열려 있다.

그래서 쿠키 속성 하나로 끝나지 않는다. 값을 명시하고, Origin을 확인하고, 자동으로 붙지 않는 토큰을 하나 요구한다. 이 셋 중 어느 것도 에이전트가 먼저 챙겨주지 않는다. 로그인이 된다는 사실만으로는 아무것도 증명되지 않기 때문이다.

YS

신윤섭

데이너스 대표 | AI 교육 & AX 컨설팅

90개 이상의 AI/AX 교육 과정을 설계하고, 50여 교육·협업 기관에서 강의했습니다. 강남세브란스, 삼성전자, 현대자동차 등 다양한 조직의 AI 역량 강화를 지원하고 있습니다.

AI 교육이 필요하신가요?

조직에 맞는 맞춤형 AI/AX 교육 프로그램을 설계해드립니다. 커리큘럼 상담부터 시작해보세요.

같은 주제의 다른 글