로그인 API는 200 OK인데, 다음 요청에 쿠키가 안 실린다.
로컬에서는 잘 되는데, 배포만 하면 로그인이 풀린다.
크롬에서는 되는데, 아이폰 사파리에서만 안 된다.
프론트엔드와 백엔드를 분리해서 쿠키 기반 인증을 붙여 봤다면 한 번쯤 겪어 봤을 증상이다. 검색하면 "withCredentials: true를 넣어라", "SameSite=None으로 바꿔라", "CORS에 allow_credentials를 켜라" 같은 처방이 쏟아지는데, 셋 다 넣어 보고 되면 넘어가고 안 되면 또 검색하게 된다.
이 글에서는 이 설정들이 각각 누가, 무엇을 기준으로 결정하는 것인지 나눠서 보고, 마지막에 하나로 엮어 본다. 결론부터 말하면 핵심은 딱 한 문장이다.
withCredentials(그리고 CORS)는 Origin을 기준으로 동작하고, SameSite는 Site를 기준으로 동작한다.
이 문장의 의미만 잡히면 나머지는 자연스럽게 따라온다.
1. 먼저 용어 정리: Origin과 Site는 다르다
Origin = 스킴 + 호스트 + 포트
세 가지가 전부 같아야 같은 Origin이다.
https :// app.example.com : 443
스킴 호스트 포트Site = 스킴 + 등록 가능한 도메인(eTLD+1)
Site는 훨씬 느슨하다. 서브도메인과 포트는 보지 않고, 등록 가능한 도메인까지만 본다.
- eTLD(effective TLD)는
com,co.kr,github.io처럼 누구나 그 아래에 도메인을 만들 수 있는 접미사다. Public Suffix List(PSL)라는 목록으로 관리된다. - eTLD+1은 eTLD에 바로 앞 한 칸을 붙인 것이다.
api.example.com→ eTLD는com→ eTLD+1은example.comshop.example.co.kr→ eTLD는co.kr→ eTLD+1은example.co.krmyapp.vercel.app→ eTLD는vercel.app→ eTLD+1은myapp.vercel.app⚠️
마지막 예시가 중요하다. vercel.app, github.io, netlify.app 같은 호스팅 도메인은 PSL에 올라가 있어서 a.vercel.app과 b.vercel.app은 서로 다른 Site다. 남의 앱이 내 앱 쿠키를 건드리면 안 되니 당연한 설계다.
스킴도 Site에 포함된다. 최신 브라우저는 http://example.com과 https://example.com을 서로 다른 Site로 본다(schemeful same-site).
예시로 확인하기
| 요청을 보내는 페이지 | 요청 대상 API | Same-Origin? | Same-Site? |
|---|---|---|---|
https://app.example.com |
https://api.example.com |
❌ 호스트 다름 | ✅ |
http://localhost:3000 |
http://localhost:8000 |
❌ 포트 다름 | ✅ |
http://localhost:3000 |
http://127.0.0.1:8000 |
❌ | ❌ 호스트 자체가 다름 |
https://example.com |
http://example.com |
❌ | ❌ 스킴 다름 |
https://myapp.vercel.app |
https://api.myservice.com |
❌ | ❌ |
https://a.vercel.app |
https://b.vercel.app |
❌ | ❌ PSL에 등록된 도메인 |
Same-Origin이면 무조건 Same-Site지만, 그 반대는 아니다.
그리고 실무에서 가장 흔한 두 구성, "서브도메인으로 프론트/API 분리"와 "로컬 개발에서 포트 분리"는 Cross-Origin이면서 Same-Site다. 이 애매한 칸이 헷갈림의 근원이다.
2. withCredentials: 쿠키를 "챙겨 갈지"는 JS가 정한다
기본 동작
- Same-Origin 요청: 쿠키가 항상 자동으로 실린다. withCredentials는 신경 쓸 필요가 없다.
- Cross-Origin 요청: 기본적으로 쿠키를 싣지 않는다. 실으려면 JS가 명시적으로 요청해야 한다.
// axios — 요청 단위
axios.get('https://api.example.com/me', { withCredentials: true });
// axios — 인스턴스 전역 (권장)
const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true,
});
// fetch
fetch('https://api.example.com/me', { credentials: 'include' });
fetch의 credentials 옵션은 세 가지 값을 가진다.
| 값 | 의미 |
|---|---|
omit |
어떤 요청에도 쿠키를 싣지 않음 |
same-origin (기본값) |
Same-Origin일 때만 실음 |
include |
Cross-Origin이어도 실음 (axios의 withCredentials: true와 같음) |
놓치기 쉬운 두 번째 역할: 응답 쿠키 "받기"
withCredentials는 쿠키를 보내는 것만 결정하지 않는다. Cross-Origin 응답에 들어 있는 Set-Cookie를 저장할지도 이 옵션이 결정한다. 그래서 이런 실수가 흔하다.
// ❌ 로그인 요청에는 안 넣고
await axios.post('https://api.example.com/login', form);
// 이후 요청에만 넣으면
await axios.get('https://api.example.com/me', { withCredentials: true }); // 401
로그인 응답의 Set-Cookie가 무시됐으니 저장된 쿠키가 없고, 이후 요청에서 아무리 withCredentials를 켜도 보낼 쿠키가 없다. 로그인 요청부터 켜야 한다. 인스턴스 전역으로 설정해 두는 게 속 편하다.
서버도 "허락"해야 한다: CORS credentials
클라이언트가 쿠키를 챙겨 보냈더라도, 서버가 CORS 응답 헤더로 허락하지 않으면 브라우저는 응답을 JS에 넘겨주지 않는다. 조건은 두 가지다.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin에는 와일드카드(*)를 쓸 수 없고 정확한 Origin이 와야 한다. "아무나 와도 되는데 쿠키 달고 와도 된다"는 건 너무 위험하기 때문이다.
FastAPI에서는 CORSMiddleware로 설정한다.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
# credentials를 쓸 거라면 와일드카드 대신 명시적인 목록으로
allow_origins=[
"http://localhost:3000",
"https://app.example.com",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
💡 CORS는 CSRF 방어가 아니다
CORS는 "응답을 JS가 읽을 수 있는가"를 통제하지, "요청이 서버에 도달하는가"를 통제하지 않는다.
Content-Type: application/json처럼 preflight가 필요한 요청은 preflight 단계에서 막히지만, form 전송이나 단순 GET 같은 simple request는 CORS 설정이 틀려도 쿠키를 달고 서버까지 가서 처리된다. 브라우저는 그 결과를 JS에게 안 보여줄 뿐이다.쿠키가 실려 가는 것 자체를 막는 건 다음에 볼 SameSite의 역할이다.
3. SameSite: 쿠키가 "따라가도 되는 상황인지"는 쿠키가 정한다
SameSite는 서버가 쿠키를 발급할 때 Set-Cookie에 붙이는 속성이다. 즉 JS가 아니라 쿠키를 발급한 서버가 정하고, 기준은 Origin이 아니라 Site다.
Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
세 가지 값
- Strict: Same-Site 요청에만 실린다. 다른 사이트에서 링크를 클릭해 들어오는 경우에도 안 실린다.
- 예: 메일 속 링크를 눌러 우리 서비스에 들어오면, 첫 화면이 로그아웃 상태처럼 보인다.
- Lax: Same-Site 요청에 실리고, Cross-Site라도 최상위 페이지 이동(top-level navigation) + 안전한 메서드(GET 등) 라면 실린다. 쉽게 말해 다른 사이트에서 링크를 눌러 주소창이 바뀌는 이동에만 따라간다.
- None: Cross-Site 요청에도 실린다. 단, 반드시
Secure와 함께 써야 한다.SameSite=None인데Secure가 없으면 최신 브라우저는 쿠키 저장 자체를 거부한다.
Cross-Site 상황에서 요청 종류별로 보면
| Cross-Site 요청 종류 | Strict | Lax | None |
|---|---|---|---|
<a href> 클릭으로 페이지 이동 (GET) |
❌ | ✅ | ✅ |
<form method="POST"> 제출로 페이지 이동 |
❌ | ❌ | ✅ |
fetch / axios (credentials 포함) |
❌ | ❌ | ✅ |
<iframe>, <img> 등 하위 리소스 |
❌ | ❌ | ✅ |
Same-Site 요청이라면 세 값 모두 쿠키가 실린다. SameSite는 Cross-Site일 때만 의미가 있다.
여기서 눈여겨볼 건 세 번째 줄이다. Cross-Site로 fetch/axios 요청을 보내면서 쿠키를 싣고 싶다면 SameSite=None 말고는 방법이 없다. Lax는 사용자가 페이지를 이동할 때만 관대하고, JS가 뒤에서 보내는 요청에는 관대하지 않다.
기본값에 기대지 말 것
SameSite를 지정하지 않은 쿠키를 Chrome(과 Chromium 계열 브라우저)은 Lax로 취급한다. 하지만 모든 브라우저가 같은 기본값을 쓰지는 않아서, 기본값에 기대면 브라우저마다 동작이 달라질 수 있다. 항상 명시하자.
FastAPI(Starlette)에서는 set_cookie의 samesite 인자로 지정한다. 참고로 Starlette의 기본값은 "lax"다.
from fastapi import FastAPI, Response
app = FastAPI()
@app.post("/login")
def login(response: Response):
# ... 인증 처리 ...
response.set_cookie(
key="session_id",
value="abc123",
httponly=True, # JS의 document.cookie로 읽지 못하게
secure=True, # HTTPS에서만 전송
samesite="lax", # "strict" | "lax" | "none"
max_age=60 * 60 * 24,
)
return {"ok": True}
4. 둘을 엮으면: 관문을 전부 통과해야 한다
Cross-Origin으로 JS 요청을 보낼 때 쿠키가 실려 가고, 그 응답을 JS가 받으려면 아래 관문을 모두 통과해야 한다.
| 관문 | 누가 정하나 | 기준 | 통과 못 하면 |
|---|---|---|---|
| ① withCredentials / credentials | 클라이언트 JS | Origin | 쿠키를 아예 안 챙김, 응답의 Set-Cookie도 무시 |
| ② SameSite | 쿠키를 발급한 서버 | Site | 그 쿠키만 요청에서 빠짐 |
| ③ CORS credentials 헤더 | API 서버 | Origin | JS가 응답을 못 읽음 (preflight 대상이면 본 요청도 안 나감) |
| ④ 서드파티 쿠키 정책 | 브라우저 | Site | Cross-Site 쿠키가 차단됨 (5장 참고) |
🏢 비유로 정리하면
- 쿠키 = 사원증
- Origin = 정확한 사무실 호수, Site = 같은 그룹사 캠퍼스
- ① withCredentials = 다른 사무실에 갈 때 사원증을 챙겨 갈지 내가 정하는 것. 안 챙기면 뒤의 규정은 볼 필요도 없다.
- ② SameSite = 사원증 뒷면에 발급 부서가 찍어 둔 사용 규정. Lax라면 "같은 캠퍼스 안에서는 어디서든 사용 가능, 다른 회사 건물에서는 정문으로 걸어 들어갈 때만 제시 가능".
- ③ CORS = 방문한 사무실이 "방문자 명단(Allow-Origin)에 있는 분이고, 사원증 달고 오셔도(Allow-Credentials) 응대해 드립니다"라고 허락하는 것.
상황별 결과표
| 구성 | withCredentials | SameSite | 쿠키 전송 |
|---|---|---|---|
Same-Site (app.example.com → api.example.com) |
false | (무관) | ❌ ①에서 막힘 |
| Same-Site | true | Strict / Lax | ✅ |
Cross-Site (myapp.vercel.app → api.myservice.com) |
true | Lax | ❌ ②에서 막힘 |
| Cross-Site | true | None; Secure | ✅ 단, ④에서 막히는 브라우저 있음 |
| Cross-Site | false | None; Secure | ❌ ①에서 막힘 |
※ ③ CORS는 모든 경우에 별도로 맞춰져 있어야 한다.
정리하면, "withCredentials 켰는데 쿠키가 안 가요"라면 ②를, "SameSite=None으로 바꿨는데 쿠키가 안 가요"라면 ①을 의심하면 된다.
5. 마지막 복병: 서드파티 쿠키 차단
SameSite=None으로 Cross-Site 요청에 쿠키를 싣는다는 건, 브라우저 입장에서 그 쿠키가 서드파티 쿠키라는 뜻이다. 그리고 서드파티 쿠키는 브라우저 정책에 따라 막힐 수 있다. (2026년 11월 기준)
- Chrome: 일반 모드에서는 기본 허용, 시크릿 모드에서는 기본 차단. Google은 한때 서드파티 쿠키를 없애겠다고 했다가 계획을 철회했다.
- Safari: 기본 차단.
- Firefox: 사이트별로 분리 저장(Total Cookie Protection)하고, 알려진 추적 도메인은 차단.
- Brave: 기본 차단.
"크롬에서는 되는데 사파리에서만 안 돼요"의 정체는 대부분 이것이다. 서버와 클라이언트 설정을 아무리 맞춰도 브라우저가 거부하면 끝이다. 그래서 쿠키 인증을 Cross-Site 구성에 의존하지 않는 것이 정답에 가깝다.
6. 시나리오별 처방
A. 로컬 개발: localhost:3000 → localhost:8000
- 판정: Cross-Origin, Same-Site
- 필요한 것: ① withCredentials ✅, ③ CORS ✅, ② SameSite는 Lax면 충분
- 주의할 점
- 브라우저 주소창은
localhost, API base URL은127.0.0.1식으로 섞으면 Cross-Site가 되어 Lax 쿠키가 빠진다. 호스트를 통일하자. Secure쿠키는 원칙적으로 HTTPS에서만 동작한다. 최근 Chrome과 Firefox는http://localhost를 예외로 봐 주지만 브라우저마다 다를 수 있으니, 개발 환경에서는 설정값으로secure를 끄는 편이 안전하다.
- 브라우저 주소창은
response.set_cookie(
key="session_id",
value=token,
httponly=True,
secure=settings.COOKIE_SECURE, # 로컬은 False, 운영은 True
samesite="lax",
)
- 더 간단한 방법: 개발 서버 프록시. Vite의
server.proxy나 Next.js의rewrites로/api요청을 백엔드로 넘기면, 브라우저 입장에서는 Same-Origin이 되어 withCredentials도 CORS도 필요 없어진다.
// vite.config.js
export default {
server: {
proxy: {
// /api/... 요청을 http://localhost:8000/api/... 로 전달
'/api': 'http://localhost:8000',
},
},
};
B. 서브도메인 운영: app.example.com → api.example.com
- 판정: Cross-Origin, Same-Site
- 필요한 것: ① ✅, ③ ✅, ② Lax (또는 Strict)
- 가장 권장하는 구성이다. Same-Site라서 서드파티 쿠키 차단의 영향도 받지 않는다.
SameSite=None을 쓸 이유가 없다. - 쿠키에
Domain속성을 붙이지 않으면api.example.com에만 전송되는 host-only 쿠키가 된다. 프론트에서 API로만 쿠키를 보내면 되는 구조라면 이걸로 충분하다.
C. 완전히 다른 도메인: myapp.vercel.app → api.myservice.com
- 판정: Cross-Origin, Cross-Site
- 억지로 맞추려면: ① ✅ + ③ ✅ + ②
SameSite=None; Secure - 하지만 ④ 서드파티 쿠키 차단에 걸려 Safari 등에서 깨진다.
- 처방: 구성 자체를 Same-Site 또는 Same-Origin으로 바꾼다.
- 프론트에 커스텀 도메인을 붙여
app.myservice.com으로 만든다 → 시나리오 B가 된다. - 프론트를 서빙하는 쪽(nginx, Next.js rewrites, Vercel rewrites 등)에서
/api를 백엔드로 프록시한다 → Same-Origin이 된다.
- 프론트에 커스텀 도메인을 붙여
server {
listen 443 ssl;
server_name app.example.com;
# ssl_certificate 등 설정은 생략
# 프론트엔드 정적 파일
location / {
root /var/www/app;
try_files $uri $uri/ /index.html;
}
# /api/ 로 들어온 요청은 경로 그대로 백엔드로 전달
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
7. 디버깅: 크롬 개발자 도구로 원인 찾기
- Network 탭 → 요청 선택 → Cookies 탭: 요청에 실린 쿠키를 볼 수 있다.
show filtered out request cookies를 체크하면 실리지 않은 쿠키와 그 이유(SameSite 때문인지 등)를 함께 보여 준다. - Network 탭 → 로그인 응답 → Headers:
Set-Cookie옆에 노란 경고 아이콘이 있으면 쿠키 저장이 거부된 것이다. 마우스를 올리면 이유가 나온다. (예:SameSite=None인데Secure가 없음) - Application 탭 → Cookies: 쿠키가 실제로 저장됐는지, SameSite/Secure 값이 의도대로인지 확인한다.
- Console: CORS 에러 메시지는
Access-Control-Allow-Origin과Access-Control-Allow-Credentials중 무엇이 문제인지 꽤 친절하게 알려 준다.
8. 보너스: SameSite와 CSRF
SameSite=Lax/Strict는 CSRF 방어에 큰 도움이 된다. 공격자 사이트에서 우리 API로 POST를 날려도 쿠키가 실리지 않기 때문이다. 반대로 SameSite=None을 쓴다면 CSRF 토큰 같은 별도 방어가 필수다.
함정이 하나 있다. SameSite는 Site 단위라서, 같은 Site 안의 다른 서브도메인(예: 취약점이 있는 blog.example.com)에서 보내는 요청은 막지 못한다. 사용자 콘텐츠를 올리는 서브도메인이나 외부 서비스에 위임한 서브도메인이 있다면 이 점을 염두에 두자.
정리
| 질문 | 답 |
|---|---|
| withCredentials는 무엇을 정하나? | Cross-Origin 요청에 쿠키를 싣고, 응답의 Set-Cookie를 받을지 (클라이언트 JS가 결정) |
| SameSite는 무엇을 정하나? | Cross-Site 요청에 이 쿠키가 따라가도 되는지 (쿠키 발급 서버가 결정) |
| 판단 기준은? | withCredentials·CORS는 Origin, SameSite는 Site |
| 서브도메인 분리 / 로컬 포트 분리는? | Cross-Origin + Same-Site → withCredentials + CORS + SameSite=Lax |
| 완전히 다른 도메인은? | SameSite=None; Secure로 되긴 하지만 서드파티 쿠키 차단에 걸린다 → 도메인 구성을 바꾸자 |
세 줄 요약
- withCredentials는 "챙겨 갈 의사", SameSite는 "따라가도 되는 범위", CORS는 "받아 주겠다는 허락" 이다. 셋 다 맞아야 한다.
- Origin과 Site를 구분하자. 서브도메인 분리는 Cross-Origin이지만 Same-Site다.
SameSite=None은 최후의 수단이다. 가능하면 Same-Site 구성(서브도메인, 리버스 프록시)으로 문제 자체를 없애자.
참고 자료
- web.dev — Understanding "same-site" and "same-origin": https://web.dev/articles/same-site-same-origin
- web.dev — SameSite cookies explained: https://web.dev/articles/samesite-cookies-explained
- MDN — 교차 출처 리소스 공유 (CORS): https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
- MDN — Set-Cookie: https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Set-Cookie
- FastAPI — CORS: https://fastapi.tiangolo.com/tutorial/cors/
'Web 일반' 카테고리의 다른 글
| [Web 일반] JWT 로그인: Access Token과 Refresh Token (0) | 2026.11.10 |
|---|---|
| [Web 일반] IP Binding (0) | 2025.10.09 |
| [Web 일반] HTTP Cache-Control 헤더 (0) | 2025.10.07 |
| [Web 일반] IP 주소 0.0.0.0의 의미와 활용 (0) | 2025.05.31 |
| [Web 일반] HTTPS 동작 원리 (0) | 2025.05.08 |
