인증과 Next.js (3) - Axios + 쿠키 인증: withCredentials와 CORS의 콜라보
시리즈 목차: 인증과 Next.js
- (1편) Next.js + Axios와 CORS 에러 제대로 이해하기
- (2편) 쿠키(Cookie) 완전 정복 — Secure, HttpOnly, SameSite의 진짜 의미
- (3편) Axios + 쿠키 인증 — withCredentials와 CORS의 콜라보 ← 현재 글
- (4편) BFF 패턴 & Proxy Handler — Next.js를 API 게이트웨이로
# Axios + 쿠키 인증 - withCredentials와 CORS의 콜라보
프론트엔드 개발자라면 알아야 할 것들 (3)
Axios + 쿠키 인증 — withCredentials와 CORS의 콜라보
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
왜 그럴까? 만약 크로스오리진 요청에 쿠키가 자동으로 포함된다면, 모든 사이트가 사용자의 인증 쿠키를 가지고 다른 서비스에 요청을 보낼 수 있게 됩니다. 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 설정
이 세 가지 축이 모두 올바르게 맞아야 합니다.
4-1. 클라이언트 조건 (axios)
tsaxios.get("https://api.example.com/user", { withCredentials: true, // 필수 });
4-2. 서버 조건 (CORS 응답 헤더)
응답 헤더에 반드시 포함되어야 하는 것:
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
크로스오리진 환경에서 쿠키가 전송되려면 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로 모이는지를 한눈에 보여줍니다.
mermaid
1편에서 다룬 CORS 응답 헤더, 2편에서 정리한 SameSite 설정,
그리고 3편의 withCredentials가 이렇게 합쳐져야만 크로스오리진 환경에서
쿠키 기반 인증이 제대로 동작합니다.
7. Axios 인스턴스 설정 — 실전 패턴
기본 인스턴스 설정
ts// api/client.ts (예시) import axios from "axios"; export const 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"; export const 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; }> = []; // 대기 중인 요청들을 처리하는 함수 const processQueue = (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) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then(() => apiClient(originalRequest)); } originalRequest._retry = true; isRefreshing = true; try { // 리프레시 토큰으로 새 액세스 토큰 발급 // (리프레시 토큰은 HttpOnly 쿠키에 있으므로 withCredentials로 자동 전송) await apiClient.post("/auth/refresh"); processQueue(null); return apiClient(originalRequest); } catch (refreshError) { processQueue(refreshError as AxiosError); // 갱신 실패 → 로그인 페이지로 이동 window.location.href = "/login"; return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } ); export default 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를 사용하고 있는가?
문제 2: 로그인은 됐는데 다음 요청에서 401 에러
체크리스트:
☐ Set-Cookie 응답이 오기는 했는가? (네트워크 탭에서 확인)
☐ 브라우저 Application 탭에서 쿠키가 실제로 저장되어 있는가?
☐ 저장된 쿠키의 SameSite 속성을 확인하라. None이어야 하는데 Lax/Strict는 아닌가?
☐ Domain 설정이 올바른가? (api.example.com과 example.com은 다름)
문제 3: Preflight는 성공하는데 실제 요청에서 CORS 에러
체크리스트:
☐ OPTIONS 요청과 실제 요청에서 서버의 응답 헤더가 동일한가?
☐ 일부 프레임워크는 OPTIONS 라우트를 별도로 등록해야 함
☐ Access-Control-Allow-Headers에 사용 중인 커스텀 헤더가 포함되어 있는가?
10. 정리
크로스오리진 환경에서 쿠키 기반 인증이 동작하려면
브라우저, 클라이언트 코드, 서버 코드가 모두 일치해야 합니다.
성공 조건 체크리스트:
클라이언트 (axios)
✅ withCredentials: true
서버 (CORS 응답 헤더)
✅ Access-Control-Allow-Origin: {정확한 Origin} (* 금지)
✅ Access-Control-Allow-Credentials: true
서버 (Set-Cookie 헤더)
✅ HttpOnly (XSS 방어)
✅ Secure (MITM 방어)
✅ SameSite=None; Secure (크로스사이트 전송 허용, 동일 사이트면 Lax)
이 조건들이 왜 필요한지를 1, 2편과 함께 이해했다면
어디서 문제가 생겼는지 디버깅하는 것도 훨씬 빠를 것입니다.
다음 편에서는 이 모든 복잡한 설정을 Next.js의 아키텍처로 단순화하는 BFF 패턴과
Proxy(Middleware) 핸들러를 다룹니다.
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!