[친구하자] 크로스 도메인 환경에서 Refresh Token 쿠키 소실 문제

백엔드, 프론트 도메인이 다른 크로스 도메인의 Production 환경에서 Refresh Token이 사라지는 문제에 대해 정리해보았습니다! (해결은 다음편)

크로스 도메인 환경에서 Refresh Token 쿠키 소실 트러블슈팅

  • 개발 환경에서는 token관련 문제가 전혀 없었다. 그런데 배포한 후 로그인이 새로운 탭에서 유지되지 않는 문제를 해결하려고 하다가 Refresh Token이 새로고침하면 사라지는걸 발견하였다.

    어디갔어.. 내 refresh token 🥲

  • 로그인이 유지되지 않는 문제는 왜인지 알았기 때문에 금방 해결하였지만, refresh token이 사라지는 문제가 더 나를 힘들게 만들었다..
  • 따라서 왜 그런 문제가 발생하는지 좀 더 알아보고자 했다.

문제 상황

  • Local 환경: Refresh Token 쿠키가 정상적으로 유지됨 ✅
  • Production 환경 : 로그인은 성공하지만, 새로고침하면 Refresh Token 쿠키가 사라짐 👻 (Refresh Token은 HttpOnly Cookie에 저장함)
  • 브라우저 개발자 도구에서 확인한 쿠키 만료 시간: 30일 ✅

    처음엔 만료 시간이 잘못 설정되어있는줄 알았다.

  • 환경 정보

    Local 환경:
    - Frontend: http://localhost:3000
    - Backend:  http://localhost:8080
    - 동일 호스트(localhost), 다른 포트 → Same-Site
    
    Production 환경:
    - Frontend: https://chingoo-frontend.vercel.app
    - Backend:  https://your-backend-domain.com
    - 다른 도메인 → Cross-Site
    

원인 분석

1. 잘못된 쿠키 설정

application-prod.yml의 문제:

app:
  cookie:
    secure: false     # ❌ HTTPS 환경인데 false
    sam-site: Lax     # ❌ 크로스 도메인인데 Lax
    max-age: 2592000  # ✅ 30일 (정상)

2. SamSite 쿠키 정책 이해하기

SamSite 속성이란?

  • CSRF(Cross-Site Request Forgery) 공격을 방어하기 위해 도입된 쿠키 정책으로, 언제 쿠키를 전송할지를 제어

    CSRF공격 이란?

    사용자가 자신의 의지와는 상관없이 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격 공격자가 사용자의 세션을 가로채는 방식으로 일어난다.

  • SamSite 3가지 값

    1. Strict
      • 동일 사이트에서만 쿠키 전공
      • 모든 크로스 사이트 요청에서 차단
      • 최고 수준 보안이 필요한 경우에 사용 (매우 엄격해서 잘 안씀)
    2. Lax (기본값)
      • TOP-LEVEL 네비게이션 + GET 요청만 허용
      • POST, PUT, DELETE 등은 차단
      • 일반적인 Sam-Site 환경 (프론트엔드와 벡엔드가 같은 도메인)
    3. None
      • 모든 크로스 사이트 요청에서 쿠키전송
      • 단, Secure=true 필수 (HTTPS)
      • 크로스 도메인 환경 (프론트엔트와 백엔드 도메인 다름)

브라우서의 SamSite=Lax 동작 분석

시나리오 1: 로그인 시 (최초 요청)

POST https://your-backend.com/api/v1/auth/login
Origin: https://your-frontend.com
Content-Type: application/json

{"email": "user@example.com", "password": "..."}

백엔드 응답:

HTTP/1.1 200 OK
Set-Cookie: refreshToken=eyJhbGc...;
            Path=/;
            Max-Age=2592000;
            HttpOnly;
            Secure;
            SameSite=Lax  ← 여기가 문제! ❌
Access-Control-Allow-Origin: https://chingoo-frontend.vercel.app
Access-Control-Allow-Credentials: true
  • 쿠키는 설정됨 (브라우저가 Set-Cookie 헤더를 받아서 저장)
  • 개발자 도구에서 쿠키 확인 가능
  • Expires/Max-Age도 30일로 정상

시나리오 2: 새로고침 시 (두번째 요청)

  • 사용자가 새로고침 또는 페이지 이동 시, 프론트엔드가 자동으로 사용자 정보를 가져오는 API 호출:
// 프론트엔드 코드 (자동 실행)
useEffect(() => {
  axios.get('https://your-backend.com/api/v1/users/me', {
    withCredentials: true  // 쿠키 전송 요청
  });
}, []);

브라우저의 요청:

GET https://your-backend.com/api/v1/users/me
Origin: https://your-frontend.com
Cookie: (없음!) <- SamSite=Lax가 쿠키 전송을 차단!
  • TOP-LEVEL 네비게이션이 아니고 JavaScript 요청이기 때문에 쿠키 전송이 안됌

    TOP-LEVEL 네비게이션은 아래와 같다.

    1. 주소창에 URL을 직접 입력
    2. <a href = "..."> 링크 클릭
    3. window.location.href 변경
         // ❌ 이런 JavaScript 요청은 TOP-LEVEL 네비게이션이 아님
         axios.get('https://api.example.com/user');
      
         // ✅ 이런 요청만 TOP-LEVEL 네비게이션으로 인정
         window.location.href = 'https://api.example.com/user';
         <a href="https://api.example.com/user">클릭</a>
      

시나리오 3: POST 요청

  • 사용자가 폼을 제출하는 등 POST 요청을 보낼 때:
POST https://your-backend.com/api/v1/some-action
Origin: https://your-frontend.com
Cookie: (없음!) <- SamSite=Lax가 쿠키 전송을 차단

결과:

  • Refresh Token 쿠키가 전송되지 않음
  • 백엔드에서 인증 실패 (401 Unauthorized)
  • 사용자는 다시 로그인해야함

해결

  • application-prod.yml 수정
app:
  cookie:
    secure: true       # HTTPS 필수
    same-site: None    # 크로스 도메인 허용
    max-age: 2592000   # 30일
  • 이로 인한 CSRF 공격에 대한 방어는 다음 편에서 다루기로 한다.

참고 자료