CORS만으로는 부족했다 — Next.js에서 외부 API와 HttpOnly 쿠키로 인증하기
로그인은 되는데, 새로고침하면 로그인이 풀렸습니다. 분명 백엔드에 CORS도 다 열어줬는데 말이죠. DevTools를 열어보니 Set-Cookie 옆에 작은 노란 경고 삼각형이 떠 있었습니다. 이 글은 그 경고가 무슨 의미였는지, 그리고 "CORS를 맞췄으니 끝"이라고 생각했던 게 왜 착각이었는지에 대한 기록입니다.
상황
구조는 흔한 형태였습니다.
- 프론트엔드: Next.js (App Router), 개발은
http://localhost:3000 - 백엔드: 별도 외부 API 서버 (
https://api.example.com/insight, Express + Cloudflare) - 인증: access token은 메모리(axios 헤더), refresh token은 HttpOnly 쿠키
로그인 요청을 보내면 백엔드가 Set-Cookie로 refresh token을 내려줍니다. access token은 메모리에 들고 있다가 만료되면 POST /auth/refresh로 갱신하는, 이른바 silent refresh 패턴입니다.
문제는 새로고침이었습니다. 페이지를 새로고침하면 메모리의 access token이 날아가고, 첫 요청이 401을 받으면 인터셉터가 /auth/refresh를 호출해 세션을 복구해야 합니다. 그런데 그 refresh 요청에 쿠키가 실리지 않았습니다. 백엔드는 쿠키 없는 요청을 받고 401을 던졌고, 로그인은 그대로 풀렸습니다.
CORS는 이미 맞춰둔 상태였습니다. Access-Control-Allow-Origin도, Access-Control-Allow-Credentials: true도 다 있었습니다. 프론트도 withCredentials: true였고요. 그런데 왜 안 됐을까요?
CORS를 맞췄는데 왜 부족했나
여기서 제가 헷갈렸던 핵심은 이겁니다. CORS와 "쿠키가 요청에 실리는지"는 서로 다른 레이어입니다.
- CORS는 "브라우저가 이 cross-origin 응답을 JS에게 읽게 허용할 것인가"를 결정합니다. 자격증명을 포함한 요청이라면
Access-Control-Allow-Credentials: true+ 정확한 origin이 있어야 응답을 쓸 수 있습니다. - 하지만 쿠키가 그 요청에 애초에 붙느냐는 CORS가 아니라 쿠키의
SameSite속성이 결정합니다.
제 쿠키는 이렇게 내려오고 있었습니다.
Set-Cookie: refreshToken=...; HttpOnly; SameSite=Lax; Path=/insight/auth; Max-Age=2592000
SameSite=Lax가 범인이었습니다. SameSite=Lax 쿠키는 cross-site subresource 요청(즉, JS가 보내는 XHR/fetch)에는 첨부되지 않습니다. same-site 요청과, 주소창으로 이동하는 top-level navigation(GET)에서만 실립니다.
그런데 제 환경은 localhost(site = localhost)와 api.example.com(site = example.com)으로 명백한 cross-site였습니다. 그러니 새로고침 때 프론트가 JS로 보내는 POST /auth/refresh는 cross-site XHR이고, 브라우저는 SameSite=Lax 쿠키를 빼고 보냅니다. 백엔드는 쿠키 없는 refresh를 받습니다. 끝이죠.
DevTools의 그 경고 삼각형이 정확히 이 의미였습니다. "이 쿠키는 SameSite=Lax라서 cross-site 컨텍스트에서는 전송되지 않는다."
흥미로운 비대칭이 하나 있습니다. 로그인 응답에서 쿠키를 저장하는 것은 (경고와 함께) 허용되는데, 이후 cross-site XHR에서 그 쿠키를 전송하는 것은 막힙니다. 그래서 "로그인은 되는데 새로고침은 안 되는" 증상이 나왔던 겁니다.
정리하면 — CORS는 응답을 통과시키는 문이고, SameSite는 쿠키를 실어주는 문입니다. 저는 앞문만 열고 뒷문은 잠가둔 채 "왜 안 들어오지" 하고 있었던 셈입니다.
백엔드는 쿠키를 어떻게 설정해야 하나
그러면 백엔드에 뭘 요청해야 할까요. 목표는 dev(localhost)와 배포(도메인 일치) 양쪽에서 같은 설정 하나로 동작하면서 보안 정석을 지키는 것이었습니다.
| 속성 | 값 | 이유 |
|---|---|---|
HttpOnly | ✅ | JS 접근 차단 (XSS 방어) |
Secure | ✅ (흔히 누락) | refresh 토큰 필수. http localhost는 secure context로 취급돼 dev에서도 동작 |
SameSite | Lax | FE/API가 same-site(또는 same-origin)이면 Lax로 충분. None은 3rd-party 쿠키라 폐지 수순 |
Domain | 설정 안 함 (host-only) | 명시하면 프록시·서브도메인 상황에서 도메인 불일치로 쿠키가 버려짐 |
Path | /auth 같은 좁은 경로 | refresh 쿠키를 auth 엔드포인트로만 한정 — 노출 최소화 |
핵심은 Secure를 빼먹지 말 것, 그리고 **Domain을 명시하지 말 것(host-only 유지)**입니다. host-only 쿠키는 요청이 가는 호스트에 자동으로 묶이므로, 같은 origin으로 가든 같은 상위 도메인의 서브도메인으로 가든 알아서 동작합니다. 더 안전하고 더 유연하죠.
한 가지 함정이 있습니다. Secure 쿠키는 secure context에서만 저장·전송되는데, Chrome/최신 Firefox는 http://localhost를 secure context로 취급하므로 dev에서도 문제없습니다. 다만 Safari나 LAN IP(192.168.x.x로 모바일 실기기 테스트)는 secure context가 아니라 깨질 수 있습니다. 전 브라우저 패리티가 필요하면 dev를 https로 띄우면 됩니다(next dev --experimental-https).
Next.js에서의 best practice — 우선순위
cross-site 쿠키 문제를 푸는 방법은 사실 여러 개이고, 명확한 우선순위가 있습니다.
- FE와 API를 같은 상위 도메인에 배치 (
app.x.com+api.x.com, 쿠키Domainhost-only,SameSite=Lax). 프록시 hop이 없고 레이턴시가 가장 낮습니다. 외부 API 토폴로지의 정석입니다. - 엣지/게이트웨이 레벨 reverse proxy (Cloudflare Worker·nginx가
x.com/api→ BE 매핑). same-origin이면서 앱 서버를 안 거칩니다. - Next.js rewrites 프록시. 동작하지만 "앱 서버를 프록시로 쓰는" 가장 약한 same-origin 옵션입니다. 서버리스 배포면 모든 API 호출이 함수 invocation이 된다는 비용이 있습니다.
SameSite=None; Secure+ CORS credentials. cross-site에서 당장은 되지만 3rd-party 쿠키라 브라우저 privacy 정책에 취약합니다. 권장하지 않습니다.
가장 깔끔한 형태를 더 밀어붙이면 BFF 패턴이 있습니다. Next 서버가 세션을 쥐고 서버사이드로 API와 통신해서 브라우저 JS에는 토큰이 아예 안 내려가는 구조죠. 프록시를 쓰는 순간 이미 BFF의 절반까지 와 있는 셈입니다.
저는 인프라를 건드릴 수 없고 프론트만으로 즉시 풀어야 하는 상황이었습니다. 그래서 3번 — Next rewrites 프록시를 택했습니다. best practice냐고 물으면 정직하게는 "맞는 방향의 차선책"입니다. 1·2번이 더 낫지만, 주어진 제약 안에서는 합리적인 stopgap이라고 판단했습니다.
제가 해결한 방법
핵심 아이디어는 브라우저가 외부 API를 직접 부르지 않게 하는 것입니다. 브라우저는 same-origin인 /insight/*로만 호출하고, Next 서버가 그걸 실제 백엔드로 프록시합니다. 그러면 브라우저 입장에서는 모든 게 same-origin이라 SameSite 제약도, CORS도 사라집니다.
next.config.js에 rewrites를 추가했습니다.
jsasync rewrites() { // 브라우저는 /insight/*로 호출(same-origin) → Next가 실제 백엔드로 프록시. // cross-site SameSite·CORS 제약 회피. target 미설정 시 비활성. const target = process.env.API_PROXY_TARGET; if (!target) return []; return [{ source: "/insight/:path*", destination: `${target}/:path*` }]; }
그리고 axios의 base URL을 외부 절대 URL에서 /insight로 바꿨습니다.
bashNEXT_PUBLIC_API_URL=/insight API_PROXY_TARGET=https://api.example.com/insight
여기서 한 가지 비자명한 결정이 있었습니다. 왜 흔히 쓰는 /api가 아니라 /insight를 base로 잡았을까요?
쿠키의 Path=/insight/auth 때문입니다. 프록시로 same-origin을 만들어도, 브라우저가 보는 경로가 쿠키 Path와 맞지 않으면 여전히 쿠키가 안 실립니다. 만약 base를 /api로 잡으면 refresh 요청은 /api/auth/refresh가 되는데, 쿠키 Path는 /insight/auth라 매칭이 안 됩니다. base를 /insight로 두면 refresh가 /insight/auth/refresh가 되어 Path와 정확히 맞물립니다. 백엔드 쿠키를 한 줄도 안 고치고 통과시키는 트릭이죠.
(부수적으로, 이 프로젝트의 MSW mock이 NEXT_PUBLIC_API_URL === "/api"일 때만 켜지도록 되어 있어서, /insight를 쓰면 mock과도 자연히 충돌하지 않습니다.)
흐름을 그림으로 보면 이렇습니다.
mermaid
CSP도 자동으로 정리됐습니다. 외부 origin으로 나가는 호출이 사라지니 connect-src가 'self'로 좁혀졌습니다. CORS 설정 자체가 더 이상 필요 없어진 것은 덤입니다(브라우저↔Next는 same-origin, Next↔API는 server-to-server라 CORS 영역 밖이니까요).
남는 이야기
이 프록시는 어디까지나 stopgap입니다. 정직하게 짚어두면 이렇습니다.
- 백엔드 쿠키는 따로 고쳐야 합니다. 프록시는 전송 계층의 막힘만 뚫었을 뿐이고,
Secure누락 같은 건 그 자체로 고쳐야 할 보안 항목입니다. - 배포 때 FE/API 도메인을 일치시키면 프록시를 떼어낼 수 있습니다. 그게 우선순위 1번이고, 현재 프록시는 거기까지의 다리입니다.
- 그래서 설정을 코드에 하드코딩하지 않고 env 레이어에만 뒀습니다. 전환은 환경변수 한 줄로 끝나야 하니까요.
결국 이 삽질에서 가장 크게 남은 건 한 문장입니다. CORS가 열렸다고 쿠키가 실리는 게 아니다. 둘은 다른 문이고, cross-site 환경에서 HttpOnly 쿠키로 인증한다면 진짜 싸워야 할 상대는 CORS가 아니라 SameSite와 origin 경계였습니다.
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!