
회사에서 같이 일하던 동료에게 이상한 현상이 하나 발생했습니다.
같은 IP를 쓰고 있는데, 어떤 페이지에서는 로그인 후 쿠키가 잘 저장되고, 다른 페이지에서는 쿠키가 전혀 찍히지 않는 문제였습니다.
- 프론트 A:
http://192.168.123.123/
- 프론트 B:
http://192.168.123.123/ai
- 백엔드 API:
http://192.168.123.123:9100
두 프론트엔드는 모두 같은 IP를 사용하고 있고, API 서버만 포트 9100으로 분리된 구조였습니다.
프론트 A에서는 로그인 요청 후 리프레시 토큰 쿠키가 잘 저장되는데,
프론트 B에서는 같은 API에 같은 로그인 요청을 보내도 쿠키가 전혀 저장되지 않았습니다.
처음에 제가 의심한 건 이런 것들이었습니다.
- SameSite 설정 문제인가?
- Path/Domain 설정이 꼬인 건가?
그래서 서버 설정, 쿠키 옵션설정까지 하나씩 확인했지만,
결국 당장 문제를 해결한 결정적인 단서는 프론트 쪽
credentials 옵션이었습니다.왜 프론트 B에서는 쿠키가 안 찍혔을까?
브라우저 입장에서 상황을 다시 보면 이렇게 정리할 수 있습니다.
http://192.168.123.123(포트 80)
http://192.168.123.123:9100(포트 9100)
호스트(IP)는 같지만, 포트가 다르면 origin은 서로 다릅니다.
그래서
192.168.123.123 → 192.168.123.123:9100 요청은 브라우저 기준으로 CORS 요청이 됩니다.여기서 중요한 포인트가 하나 있습니다.
CORS + Set-Cookie + credentials
MDN의 에 이런 내용이 나옵니다:
Fetch API나 XHR 요청이 CORS를 사용할 때,요청에 credentials가 포함되지 않으면 응답의Set-Cookie헤더는 브라우저에서 무시된다.
이걸 이번 사례에 그대로 적용해 보면:
- 프론트 A
- 로그인 요청을 보낼 때 이미
credentials: "include"를 사용하고 있음 - 그래서 응답의
Set-Cookie가 정상적으로 브라우저에 저장됨.
- 프론트 B
- 대략 이런 식으로 요청을 보내고 있었다고 볼 수 있음:
- 기본값인
credentials: "same-origin"에서는 - 브라우저가 응답의
Set-Cookie헤더를 그냥 무시해 버린다.
192.168.123.123 (80) → 192.168.123.123:9100 요청이 cross-origin이기 때문에,결국 우리가 겪었던 현상을 한 줄로 정리하면:
프론트 A는 credentials를 포함해서 요청을 보내 쿠키가 잘 저장됐고, 프론트 B는 credentials를 빼먹어서, Set-Cookie가 와도 브라우저가 무시해 버렸다.
이번 일을 겪으면서, “같은 IP라서 괜찮겠지”라고 생각했던 부분이 실제로는 origin 기준에서는 완전히 다른 요청이 될 수 있다는 점, 그리고 CORS 환경에서 쿠키를 주고받으려면 꼭
credentials까지 같이 봐야 한다는 점을 몸으로 깨닫게 되었습니다.이제 여기서부터는
- CORS가 정확히 뭘 기준으로 동작하는지,
credentials: "include"/"same-origin"/"omit"이 어떻게 다른지,
- 쿠키의 SameSite, Domain, Path 설정이 여기에 어떻게 얽히는지
를 하나씩 풀어보려고 합니다.
CORS는 뭘 기준으로 동작하고 뭘까?
CORS가 보는 기준은
site가 아니라 origin입니다.origin = scheme + host + port
예를 들어:
http://192.168.0.60→ origin:http + 192.168.0.60 + 80
http://192.168.0.60:9100→ origin:http + 192.168.0.60 + 9100
https://www.naver.com→ origin:https + www.naver.com + 443
두 요청의 origin이 완전히 같으면 same-origin, 셋 중 하나라도 다르면 cross-origin이 됩니다.
이때부터 브라우저는 CORS 규칙을 적용하기 시작합니다.
브라우저가 입장에서 CORS는 “보안 필터”
브라우저 입장에서 보면 대략 이렇게 나눌 수 있습니다.
- same-origin 요청
- “내 사이트 안에서 자기 서버랑 통신”이므로 비교적 자유롭게 허용
- cross-origin 요청
- “다른 사이트 서버랑 통신”이기 때문에 서버가 명시적으로 허용하지 않으면, JS에서 응답을 읽지 못하게 막음
CORS는 요청을 막는 게 아니라, 응답을 JS에서 읽는 걸 제어하는 보안 장치입니다.
여기서 중요한 포인트는:
CORS는 “요청을 막는 장치”라기보다,“응답을 JS에게 넘길지 말지 결정하는 장치”에 가깝다.
실제 동작을 일반화해서 정리하면:
- 브라우저가
fetch로 cross-origin 요청을 보냄
- 요청은 서버까지 실제로 감
- 서버가 응답을 돌려줌
- 응답 헤더에
Access-Control-Allow-Origin등 CORS 허용 헤더가 없음
- 브라우저가 네트워크 레벨에서는 응답을 받았지만, JS 코드에 응답을 넘겨주지 않고 차단
- 콘솔에 CORS 에러가 출력됨
즉, 응답이 네트워크 레벨에서 “안 오는” 게 아니라,
브라우저가 “이건 너(자바스크립트)가 보면 안 돼” 하고 막는 구조입니다.
참고로, Content-Type이 특정 조건을 벗어나거나, 커스텀 헤더를 쓰는 등 preflight(OPTIONS) 조건을 만족하면 브라우저가 먼저 OPTIONS 요청을 보내서 서버가 허용하는지 확인하고, 이 단계에서 허용되지 않으면 아예 본 요청 자체를 보내지 않는 경우도 있습니다.
그럼 누군가 응답을 하이재킹할 수 있는거 아니야?
이 부분은 어떤 공격자를 상정하는지에 따라 나눠서 봐야 합니다.
1. 네트워크 공격자
공격자가 네트워크 구간을 장악하고 있다면:
- HTTP 통신은 그대로 볼 수 있고, 조작도 가능합니다.
- 이건 CORS 유무와 관계가 없습니다.
이 상황에서 우리를 지켜주는 건:
- HTTPS(TLS) 로 트래픽을 암호화하는 것
- CORS가 아니라 TLS/인증서의 영역입니다.
정리하면
“응답을 누군가 하이재킹할 수 있냐?” → 네트워크가 HTTP고, 공격자가 트래픽을 볼 수 있다면 CORS와 상관없이 원래부터 가능 → 이건 CORS 문제가 아니라 TLS(HTTPS)를 써야 하는 보안 이슈입니다.
2. 악성 웹사이트 / 다른 origin의 JS
CORS + Same-Origin Policy(SOP)가 진짜로 막으려는 건 이 쪽입니다.
- 악성 사이트
evil.com이 사용자의 브라우저에서 JS를 실행하면서 https://bank.com/account같은 민감한 API에 요청을 보내고- 응답을 읽어서 계좌 정보, 개인 정보를 훔치는 시나리오
이때 CORS/SOP는 다음과 같은 역할을 합니다.
- 요청 자체는 어느 정도 갈 수 있어도,
- 응답 바디/일부 헤더를 JS가 읽지 못하게 막는다.
그래서
evil.com의 JS는:- 요청이 성공했는지, 응답 내용이 뭔지, 계좌 정보가 뭔지 직접 읽을 수 없습니다.
- 최소한 데이터 유출은 막을 수 있게 됩니다.
결국:
CORS는 “네트워크 상의 패킷을 가로채는 공격”을 막는 게 아니라,악성 웹페이지의 JS가 다른 origin의 민감한 응답을 읽어서 악용하는 것을 막는브라우저 레벨의 보호 장치라고 볼 수 있습니다.
Credentials 옵션은 어떻게 쓸 수 있을까?
credentials 옵션은 요청에 쿠키/인증 정보를 실을지, 그리고 응답의 Set-Cookie를 브라우저가 반영할지를 함께 결정하는 설정입니다.여기서 말하는 “credentials”에는 다음이 포함됩니다.
- 쿠키(Cookie)
- TLS 클라이언트 인증서
Authorization같은 인증 헤더
fetch에서 사용할 수 있는 값은 세 가지입니다.참고로 기본값은"same-origin"입니다. (예전에는 브라우저별로 차이가 있었지만, 지금은 스펙/MDN 기준으로 기본값이same-origin으로 정리되어 있습니다.)
1. “omit” - “무자격” 요청
동작
- 요청 보낼 때
- 어떤 origin이든 쿠키, 세션, 인증 헤더를 전혀 보내지 않음
- 응답 받을 때
- 서버에서
Set-Cookie를 내려줘도 브라우저가 무시함
즉, “이 요청에는 그 어떤 자격 증명도 절대 쓰지 않겠다”는 의미입니다.
언제 쓸까?
- 완전 비공개 리소스를 가져올 때
- 공개 이미지 CDN, 공개 문서 JSON 등
“혹시라도 이 요청에 쿠키가 실리면 안 된다” 같은 상황에서 사용되는 보안적으로 가장 보수적인 모드입니다.
2. “same-origin” - “내 출처에만 인증 허용”
same-origin은 현재 페이지의 origin과 요청을 보내는 URL의 origin이 같을 때만 자격 증명을 사용합니다. 이 값이 기본값입니다. 동작
- 요청 보낼 때
- 요청 URL의 origin이 현재 페이지의 origin과 같다면 → 쿠키 / 세션 / 인증 헤더를 보냄
- origin이 다르면 (CORS 상황) → 쿠키 안 보냄
- 응답 받을 때
- same-origin이면 응답의
Set-Cookie를 반영 - cross-origin이면 응답의
Set-Cookie를 무시
즉, “내 origin 서버와 통신할 때만 세션/로그인을 사용하겠다”는 기본적인 상황에 맞는 모드입니다.
3. “include” - “origin 상관없이 인증까지”
동작
- 요청 보낼 때
- same-origin이든 cross-origin이든 쿠키 / 인증 정보를 포함해서 보냄
- 응답 받을 때
- 서버가 CORS를 올바르게 설정했다면
(
Access-Control-Allow-Origin/Access-Control-Allow-Credentials) → cross-origin 응답의Set-Cookie도 저장함
즉, origin이 달라도 인증된 컨텍스트를 유지하면서 요청을 보내는 모드입니다.
다만, 이 모드를 쓰면 서버도 CORS를 제대로 열어줘야 합니다.
쿠키가 전송되기 까지
브라우저가 어떤 요청에 어떤 쿠키를 붙일지 결정할 때, 개념적으로는 대략 이런 필터를 순서대로 거칩니다.
- 만료가 안 됐나?
Expires/Max-Age기준으로 이미 만료된 쿠키는 후보에서 제외
- Domain이 매칭되는가?
- 요청 호스트가
Domain속성에 해당하는지 Domain미지정이면 쿠키를 만든 호스트와 정확히 같아야 함 (host-only cookie)
- Path가 매칭되는가?
- 요청 경로가
Path로 시작하는지(프리픽스 매칭)
- Secure인데 HTTPS 요청인가?
Secure쿠키는 HTTPS 요청에만 전송
- SameSite 규칙에 맞는 컨텍스트인가?
- “현재 탑레벨 사이트”와 “요청 URL의 사이트”를 비교해서
Strict/Lax/None규칙에 맞는지 확인
- credentials 설정이 쿠키 허용 모드인가?
credentials: "omit"→ 아예 안 씀same-origin→ cross-origin이면 안 씀include→ cross-origin에도 “보낼 자격이 있는 쿠키들”을 전부 시도
이 중 하나라도 탈락하면 해당 요청의
Cookie 헤더에 포함되지 않습니다.만료, Secure 여부는 비교적 직관적인데,
SameSite / Domain / Path는 케이스가 다양해서 조금 더 자세히 봐야 합니다.1. Domain - “어느 호스트/서브도메인까지 이 쿠키를 보낼까?”
Domain은 “어떤 도메인(호스트)들에게 이 쿠키를 전송할지” 정의합니다. Domain을 지정하지 않는 경우
Set-Cookie에 Domain을 아예 쓰지 않으면, host-only 쿠키가 됩니다. - 쿠키를 만든 호스트(예:
www.bank.com)에서만 전송
api.bank.com,server.bank.com같은 서브도메인에는 전송되지 않음
즉:
www.bank.com에서Domain없이 설정한 쿠키 → 오직www.bank.com으로의 요청에만 전송
Domain을 지정한 경우
Domain=.bank.com처럼 쓰면, 그 도메인과 서브도메인들에 전송됩니다. Domain=.bank.com이면:bank.comwww.bank.comapi.bank.comserver.bank.com- … 모두 전송 대상
주의: 보안상 이유로, 브라우저는
.com, .co.kr같은 퍼블릭 Suffix에는 쿠키를 설정할 수 없습니다. (.com 전체에 쿠키를 심는 “슈퍼 쿠키”는 막혀 있음)언제 어떻게 사용할까?
- 가능하면 Domain을 지정하지 않고(host-only) 필요한 호스트에서만 쓰는 걸 추천
- 여러 서브도메인(
api.bank.com,web.bank.com등)에서 같이 써야 하는 세션이라면 그때만Domain=.bank.com같이 범위를 넓히는 편이 안전
Domain을 넓게 잡을수록 같은 도메인 계열의 다른 서비스에서도 쿠키를 읽거나 사용할 수 있으니,
꼭 필요할 때만 쓰는 게 좋습니다.
2. Path - “그 도메인 안에서 어느 URL 경로까지 보낼까?”
Path는 “요청 URL의 path가 어디부터 시작해야 이 쿠키를 붙일지”를 결정합니다. 예를 들어, 쿠키의
Path가 /shop인 경우 전송 대상:/shop
/shop/
/shop/product/1
/shop/cart
다음과 같은 요청에는 전송되지 않습니다:
/
/admin
/profile
즉, Path는 프리픽스(prefix) 매칭입니다.
- 서비스 전역에서 필요한 세션/로그인 쿠키
→ 보통
Path=/
- 특정 기능에서만 필요한 쿠키
→ 해당 기능의 경로로 제한 (
/shop,/admin,/upload등)
이렇게 범위를 좁히면, 한 도메인 안에서도 서로 다른 기능끼리 쿠키를 덜 공유하게 되어, 보안/관리 측면에서 더 안전합니다.
3. SameSite - “같은 사이트가 아닐 때, 이 쿠키를 보낼까 말까”
SameSite는 “이 쿠키를 cross-site 요청에 포함할지 말지”를 제어하는 속성입니다.가능한 값은 세 가지입니다.
SameSite=Strict
SameSite=Lax
SameSite=None(→ 반드시Secure와 함께)
최근 브라우저들은
SameSite를 안 쓰면 사실상 Lax를 기본값으로 취급합니다.“site”의 기준이 뭘까?
SameSite에서 말하는 “site”는 대략 “등록 가능한 도메인 + scheme(https/http)”입니다.https://bank.com↔https://server.bank.com- 도메인 계열이 같고, 둘 다 https → 같은 사이트
https://bank.com↔https://evil.com- 도메인 다름 → 다른 사이트
http://bank.com↔https://bank.com- scheme 다름 → “schemeful same-site” 기준에서는 다른 사이트
즉, 서브도메인은 같은 사이트,
하지만 http/https가 다르면 다른 사이트로 취급하는 방향으로 스펙이 정리되고 있습니다.
SameSite=Strict
가장 강력한 모드입니다.
- 같은 사이트에서의 요청에만 쿠키 전송
- 다른 사이트에서 오는 어떤 요청에도 쿠키가 붙지 않음
- 예: 다른 도메인에서
<img src="https://bank.com/...">로 요청해도 쿠키 X - 다른 사이트의
<form>이https://bank.com/transfer로 POST를 날려도 쿠키 X
CSRF 방어 측면에서 가장 강력하지만, 로그인 상태 유지 등 UX 측면에서 제약이 커서
진짜 민감한 쿠키에만 가끔 쓰입니다.
SameSite=Lax
대부분의 브라우저에서 기본값으로 취급하는 모드입니다.
대략적인 동작:
- same-site 요청 → 평소처럼 쿠키 전송
- 다른 사이트에서 오는 “일반적인 서브 요청” (이미지, XHR, fetch 등) → 쿠키 전송 안 함
- 다른 사이트에서의 “탑레벨 GET 이동” (링크 클릭 등) → 제한적으로 쿠키를 전송
즉,
- 링크를 클릭해서
https://bank.com으로 이동하는 정도의 흐름에서는 세션이 유지되지만,
- 다른 사이트에서 백그라운드 요청(XHR/fetch)으로 내 API를 때리는 시나리오에서는 쿠키가 붙지 않도록 막아 줍니다.
그래서 “일반적인 웹 서비스 세션 쿠키”는
Lax가 기본값으로 많이 사용됩니다.SameSite=None; Secure
SameSite=None은 말 그대로:“이 쿠키를 cross-site 요청에도 보내도 괜찮다”
는 뜻입니다. 하지만 현대 브라우저에서는 반드시
Secure와 함께 사용해야 합니다.그렇지 않으면:
- 브라우저가 이 쿠키를 아예 무시하고 설정하지 않음(Invalid 포맷으로 취급)
그래서
SameSite=None은 사실상:- HTTPS 환경에서만 쓸 수 있는 cross-site 쿠키 모드
라고 이해하는 게 편합니다.
언제 어떻게 써야할까?
- 민감한 세션 쿠키
- 가능하면
SameSite=Lax또는Strict None은 정말 cross-site가 필수인 경우(SSO, 일부 위젯 등)에만 사용
- 제3자 서비스, SSO, iframe 등에 필요한 쿠키
- 다른 사이트에 embed되거나, 여러 도메인을 넘나드는 인증 흐름이 필수라면
→
SameSite=None; Secure
- On-prem HTTP(비 SSL) 환경
SameSite=None을 쓰려면 무조건Secure필요 → HTTP에서는 브라우저가 아예 무시하기 때문에 사실상 사용 불가- 이 상태에서
HTTP + 포트만 다른 서버로 cross-origin + 쿠키 조합을 쓰려고 하면, - SameSite
- Secure
- CORS
credentials
이 네 가지가 서로 꼬이면서 이상한 버그를 만들기 쉽습니다.
이런 이유로, nginx 등으로 origin(도메인/포트)을 통일해서 HTTPS로 올린 뒤 웹 앱과 API를 리버스 프록시 뒤에 두는 구조가 더 깔끔한 해법으로 많이 쓰입니다.