1편에서 CORS를, 2편에서 쿠키 보안 속성을 다뤘습니다.
이 두 가지를 동시에 사용하는 순간 예상치 못한 문제들이 생깁니다.
withCredentials: true를 붙였는데 쿠키가 안 날아갑니다.
쿠키가 설정은 됐는데 다음 요청에서 전송되지 않습니다.
Access-Control-Allow-Origin: *을 설정했는데 여전히 에러가 발생합니다.
이 글에서는 이런 문제들이 왜 생기는지를 설명하고,
axios 인스턴스를 어떻게 구성해야 쿠키 기반 인증이 제대로 동작하는지를 다룹니다.
이번 글의 목표는 세 가지입니다.
크로스오리진 환경에서 브라우저가 언제 쿠키를 자동으로 붙이지 않는지 이해한다.
withCredentials + 서버 CORS 응답 헤더 + 쿠키 속성(SameSite/Secure)의 세 축이 어떻게 맞물리는지 정리한다.
어떤 프레임워크/프로젝트에서도 응용할 수 있는 일반적인 axios + 인터셉터 패턴을 가져간다.
1. 1·2편 복습 & 이번 편에서 다룰 문제
1편에서는 브라우저의 SOP와 CORS, Preflight 메커니즘을, 2편에서는 쿠키의 동작과 Secure/HttpOnly/SameSite를 살펴봤습니다.
이번 3편에서는 이 둘을 axios + 쿠키 기반 인증 시나리오에 실제로 엮어 보면서,
크로스오리진 환경에서 어떤 설정들이 동시에 맞아야 인증이 제대로 동작하는지를 정리합니다.
2. 크로스오리진 요청에서 쿠키가 기본으로 차단되는 이유
1편에서 SOP(Same-Origin Policy)가 다른 Origin의 응답을 차단한다고 설명했습니다.
쿠키에도 비슷한 원칙이 적용됩니다.
브라우저는 크로스오리진 요청을 보낼 때 기본적으로 쿠키를 포함하지 않습니다.
mermaid
다이어그램 정의(mermaid):
sequenceDiagram
participant Browser as 브라우저 (https://my-app.com)
participant Api as api.example.com
Note over Browser: 기본 동작 (withCredentials: false)
Browser->>Api: axios.get('https://api.example.com/user')
Note over Browser: Cookie 헤더 없음<br/>쿠키가 있어도 전송 안 함
왜 그럴까?
만약 크로스오리진 요청에 쿠키가 자동으로 포함된다면,
모든 사이트가 사용자의 인증 쿠키를 가지고 다른 서비스에 요청을 보낼 수 있게 됩니다.
2편에서 설명한 CSRF 공격이 아무런 제약 없이 가능해지는 상황이 됩니다.
3. withCredentials: true — 쿠키 포함을 명시적으로 허용
withCredentials는 axios(내부적으로 XMLHttpRequest)를 사용할 때 크로스오리진 요청에
쿠키와 인증 헤더를 포함하겠다고 명시적으로 선언하는 옵션입니다.
ts
// 이 요청은 쿠키를 포함해서 보낸다axios.get("https://api.example.com/user",{ withCredentials:true,});
그러나 클라이언트 설정만으로는 부족합니다. 서버도 반드시 이를 허용하는 응답 헤더를 보내야 합니다.
4. Credentialed Request의 CORS 조건
쿠키를 포함한 크로스오리진 요청(Credentialed Request)이 성공하려면 클라이언트 설정(axios withCredentials), 서버의 CORS 응답 헤더, 쿠키의 SameSite/Secure 설정
이 세 가지 축이 모두 올바르게 맞아야 합니다.
응답 헤더에 반드시 포함되어야 하는 것:
1. Access-Control-Allow-Origin: https://my-app.com
→ 와일드카드(*) 절대 불가
2. Access-Control-Allow-Credentials: true
→ 명시적으로 credentials 허용 선언
이 두 조건이 동시에 충족되어야 합니다. 하나라도 빠지면 브라우저가 응답을 차단합니다.
5. 왜 Access-Control-Allow-Origin: *이 안 되는가
가장 많이 실수하는 부분입니다.
❌ 잘못된 서버 설정:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
→ 브라우저: "credentials와 와일드카드 origin은 함께 사용할 수 없습니다"
→ CORS 에러 발생
왜 이 조합이 금지되어 있을까요?
Access-Control-Allow-Origin: *는 "어떤 사이트에서든 이 API를 호출할 수 있다"는 뜻입니다.
만약 여기에 credentials: true까지 허용한다면, 세상의 모든 웹사이트가 사용자의 인증 쿠키를 가지고 이 API를 호출할 수 있게 됩니다.
만약 bank.com API가 이렇게 설정되어 있다면:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
→ evil.com 에서 아래 코드가 동작하게 됨:
axios.get('https://bank.com/api/balance', {
withCredentials: true
// 피해자의 bank.com 쿠키가 자동으로 포함됨
});
→ 피해자의 잔고 정보가 evil.com으로 전송됨
이를 막기 위해 RFC 명세에서는 Credentialed Request에
와일드카드(*)를 허용하지 않도록 명시하고 있습니다.
✅ 올바른 서버 설정:
Access-Control-Allow-Origin: https://my-app.com (정확한 Origin 명시)
Access-Control-Allow-Credentials: true
6. Credentialed Request 전체 흐름 정리
설정이 올바르게 되었을 때 전체 흐름을 다이어그램으로 정리하면 다음과 같습니다.
mermaid
다이어그램 정의(mermaid):
sequenceDiagram
participant B as Client
participant S as Server
Note over B,S: [로그인 단계: CORS & 쿠키 설정]
B->>S: Login Request (Origin 포함)
S-->>B: 200 OK (Allow-Origin & Set-Cookie)
Note over B: 브라우저에 쿠키 저장됨
Note over B,S: [데이터 요청: 쿠키 포함 전송]
B->>S: GET /user (with Credentials)
S-->>B: 200 OK (User Data)
크로스오리진 환경에서 쿠키가 전송되려면 SameSite=None; Secure가 필요합니다.
2편에서 SameSite=None은 크로스사이트 요청에 쿠키를 포함하겠다는 설정이라고 설명했습니다. https://my-app.com과 https://api.example.com은 서로 다른 사이트(Cross-Site)이므로
서버가 설정하는 쿠키에 SameSite=None; Secure가 없으면
브라우저가 다음 요청에 쿠키를 포함시키지 않습니다.
크로스오리진 쿠키 전송을 위해서는 다음 세 가지가 모두 필요합니다.
클라이언트(axios): withCredentials: true
서버 CORS 응답 헤더: Access-Control-Allow-Origin: {정확한 Origin}, Access-Control-Allow-Credentials: true
서버 Set-Cookie 헤더: SameSite=None; Secure
아래 다이어그램은 이 세 가지 축이 어떻게 하나의 Credentialed Request로 모이는지를 한눈에 보여줍니다.
1편에서 다룬 CORS 응답 헤더, 2편에서 정리한 SameSite 설정,
그리고 3편의 withCredentials가 이렇게 합쳐져야만 크로스오리진 환경에서
쿠키 기반 인증이 제대로 동작합니다.
7. Axios 인스턴스 설정 — 실전 패턴
기본 인스턴스 설정
ts
// api/client.ts (예시)import axios from"axios";exportconst apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, timeout:10_000, withCredentials:true,// 모든 요청에 쿠키 포함 headers:{"Content-Type":"application/json",},});
withCredentials: true를 인스턴스 레벨에서 설정하면
개별 요청마다 옵션을 추가하지 않아도 됩니다.
401 응답 처리와 토큰 갱신 인터셉터
실제 서비스에서는 액세스 토큰이 만료됐을 때 리프레시 토큰으로 자동 갱신하는 로직이 필요합니다.
ts
// api/client.ts (예시)import axios,{ AxiosError, InternalAxiosRequestConfig }from"axios";exportconst apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, withCredentials:true,});// 토큰 갱신 중복 호출 방지를 위한 상태let isRefreshing =false;let failedQueue:Array<{resolve:(value:unknown)=>void;reject:(reason?:unknown)=>void;}>=[];// 대기 중인 요청들을 처리하는 함수constprocessQueue=(error: AxiosError |null)=>{ failedQueue.forEach((prom)=>{if(error){ prom.reject(error);}else{ prom.resolve(undefined);}}); failedQueue =[];};apiClient.interceptors.response.use((response)=> response,async(error: AxiosError)=>{const originalRequest = error.config as InternalAxiosRequestConfig &{ _retry?:boolean;};// 401 에러이고 재시도 이력이 없는 경우if(error.response?.status ===401&&!originalRequest._retry){// 이미 토큰 갱신 중이라면 대기열에 추가if(isRefreshing){returnnewPromise((resolve, reject)=>{ failedQueue.push({ resolve, reject });}).then(()=>apiClient(originalRequest));} originalRequest._retry =true; isRefreshing =true;try{// 리프레시 토큰으로 새 액세스 토큰 발급// (리프레시 토큰은 HttpOnly 쿠키에 있으므로 withCredentials로 자동 전송)await apiClient.post("/auth/refresh");processQueue(null);returnapiClient(originalRequest);}catch(refreshError){processQueue(refreshError as AxiosError);// 갱신 실패 → 로그인 페이지로 이동 window.location.href ="/login";returnPromise.reject(refreshError);}finally{ isRefreshing =false;}}returnPromise.reject(error);});exportdefault apiClient;
인터셉터의 핵심은 세 가지입니다.
중복 갱신 방지: isRefreshing 플래그로 토큰 갱신이 동시에 여러 번 호출되는 것을 막습니다.
대기열 처리: 갱신 중에 들어온 요청들을 대기열에 넣고 갱신 완료 후 일괄 처리합니다.
HttpOnly 쿠키 활용: 리프레시 토큰이 HttpOnly 쿠키에 저장되어 있으므로 withCredentials: true만으로 자동 전송됩니다. JavaScript에서 직접 토큰 값에 접근하지 않아도 됩니다.
8. 쿠키 vs localStorage — 인증 토큰 저장 방식 비교
인증 토큰을 어디에 저장할지는 프론트엔드 개발의 오래된 논쟁 주제입니다.
비교 항목
HttpOnly Cookie
localStorage
XSS 취약성
✅ 안전 (JS 접근 불가)
❌ 취약 (localStorage.getItem으로 탈취 가능)
CSRF 취약성
⚠️ SameSite 설정으로 방어
✅ 안전 (자동 전송 없음)
크로스탭 동기화
✅ 자동
❌ 별도 구현 필요
만료 처리
✅ 서버에서 제어 가능
❌ 클라이언트 의존
JS 접근 필요
❌ 불필요
✅ 필요
SSR 지원
✅ 서버에서 직접 읽기 가능
❌ 서버에서 접근 불가
결론적으로, 보안이 중요한 인증 토큰은 HttpOnly Cookie가 권장됩니다.
특히 SSR을 지원하는 환경(Next.js 등)에서는 서버에서도 쿠키를 읽어 인증 처리가 가능하기 때문에
쿠키 방식이 더 자연스럽게 통합됩니다.
9. 자주 발생하는 문제와 원인
문제 1: withCredentials 설정했는데 쿠키가 전송 안 됨
체크리스트:
☐ 서버에서 Access-Control-Allow-Origin이 와일드카드(*)가 아닌 정확한 Origin인가?
☐ 서버에서 Access-Control-Allow-Credentials: true 헤더를 보내고 있는가?
☐ 쿠키에 SameSite=None; Secure가 설정되어 있는가? (크로스 도메인의 경우)
☐ 로컬 개발 환경이 아닌 경우, HTTPS를 사용하고 있는가?