본문으로 건너뛰기
JSL107's Tech Note
돌아가기

크로스사이트 인증 쿠키 장애를 진단하고 안전하게 수정한 과정

8분 분량이 글 고치기
목차 · 7

문제: 폼은 열리지만 저장과 제출은 항상 실패했다

지원자 화면에서 지원서를 작성할 수는 있었지만 임시저장하거나 제출하려고 하면 Unauthorized가 표시되면서 항상 실패했어요.

처음에는 지원서 API나 프론트 요청 코드에 문제가 있다고 생각했어요. 하지만 지원자 동선에서 인증 가드가 처음 적용되는 요청은 임시저장과 제출이었어요. 앞 단계인 폼 조회는 인증 없이 동작했기 때문에 핵심 기능을 호출하는 순간에야 인증 문제가 드러난 거죠.

프론트는 401 응답을 받으면 토큰 갱신 경로를 자동으로 호출하도록 구현되어 있었어요. 하지만 토큰 갱신마저 실패하면서 사용자가 직접 복구할 수 없는 상태였어요.

배포 환경의 실제 요청 경로를 대조했다

로컬 설정만 살펴보는 데 그치지 않고 배포된 프론트 번들과 OpenAPI 명세, 프론트 HTTP 클라이언트, 백엔드 쿠키 설정을 직접 대조했어요.

확인 항목확인 결과
지원자 프론트공용 호스팅 서브도메인에 배포된 프론트
프론트가 호출하는 API별도 서비스 도메인의 관리자 API
인증 방식access_token을 사용하는 cookie authentication
프론트 요청 설정credentials: "include"
401 처리토큰 갱신 경로 자동 재시도
백엔드 쿠키SameSite=Lax

핵심은 프론트와 API가 서로 다른 site에 있다는 점이었어요.

프론트가 사용하는 호스팅 제공자의 공용 도메인은 Public Suffix예요. 그래서 공용 호스팅 서브도메인의 프론트와 별도 서비스 도메인의 API는 cross-site 관계예요. 이런 환경에서 SameSite=Lax 쿠키는 top-level GET 이동 시 전송될 수 있지만 프론트의 fetch나 XHR 요청에는 포함되지 않아요.

프론트에서 credentials: "include"를 지정해도 브라우저는 요청에서 access_token을 제외했어요. refresh_token도 SameSite=Lax였기 때문에 토큰 갱신 요청에 전송되지 않았고요.

백엔드는 해당 프론트 오리진에 CORS의 credentials: true를 허용하고 있었지만 쿠키 속성은 cross-site 전송을 차단하고 있었어요. CORS 설정과 인증 쿠키 정책이 서로 모순된 상태였던 거죠.

운영 인증 쿠키를 SameSite=None으로 변경했다

운영 환경에서는 인증 쿠키를 다음과 같이 발급하도록 바꿨어요.

access_token=...; Path=/; HttpOnly; Secure; SameSite=None
refresh_token=...; Path=<토큰 갱신 경로>; HttpOnly; Secure; SameSite=None

SameSite=None 쿠키에 Secure가 없으면 브라우저가 이를 거부해요. 운영 환경에서는 SameSite=None; Secure를 함께 사용하고 HTTPS를 쓰지 않는 로컬 환경에서는 기존처럼 SameSite=Lax를 유지했어요.

this.authenticationCookieOptions = {
httpOnly: true,
secure: this.isProductionEnvironment,
sameSite: this.isProductionEnvironment ? 'none' : 'lax',
};

쿠키를 발급할 때뿐 아니라 삭제할 때도 같은 속성을 사용하도록 정리했어요. 기존 쿠키 삭제 호출에는 sameSite와 secure 옵션이 전달되지 않았어요. cross-site 응답에서 삭제용 Set-Cookie가 거부되면 서버에서는 로그아웃에 성공하더라도 브라우저에 access_token이 남을 수 있거든요.

이를 막기 위해 쿠키 발급과 삭제에 공통 인증 쿠키 옵션을 사용하도록 통합했어요. refresh_token의 경로도 발급할 때와 같은 토큰 갱신 경로로 맞췄어요.

SameSite=None으로 생기는 보안 공백도 함께 막았다

SameSite=Lax를 None으로 바꾸면 인증 요청은 정상화되지만 기존 쿠키 정책이 부수적으로 제공하던 CSRF 방어도 사라져요. 쿠키 설정만 바꾸면 허용 범위가 지나치게 넓어질 수 있었어요.

먼저 CORS 허용 오리진을 정규식이 아닌 정확히 일치하는 목록으로 바꿨어요.

기존 정규식은 동일한 프론트 프로젝트 이름에 임의의 배포 식별자를 조합한 공용 호스팅 서브도메인을 폭넓게 허용했어요. 호스팅 프로젝트 이름은 계정이나 팀 단위에서만 유일하기 때문에 제3자가 비슷한 오리진을 만들 가능성을 배제할 수 없었어요. SameSite=None을 적용하면 이런 오리진에도 인증 쿠키가 전송될 수 있으므로 운영 프론트와 명시된 로컬 오리진만 허용하도록 범위를 좁혔어요.

전역 오리진 검사 가드도 추가했어요. CORS는 multipart/form-data나 일부 폼 POST 같은 simple request가 전송되는 것 자체를 막지 못하고 브라우저가 응답을 읽는 것만 제한해요. 그래서 상태를 변경하는 요청은 서버에서도 Origin을 검사하도록 했어요.

GET, HEAD, OPTIONS는 검사 대상에서 제외했어요. 나머지 요청에 허용되지 않은 Origin이 있으면 403 Forbidden으로 거부하고, 서버 간 호출처럼 Origin 헤더가 없는 요청은 그대로 통과시켜요.

사용자에게 노출되는 401 메시지도 정리했다

인증에 실패했을 때 NestJS의 기본 메시지인 Unauthorized가 그대로 노출되는 문제도 함께 고쳤어요.

먼저 프레임워크가 기본으로 생성한 예외인지 판별했어요. 기본 예외는 서비스의 한국어 오류 메시지로 바꾸고, 설명을 직접 지정한 예외와 객체형 커스텀 예외, ValidationPipe 메시지 배열은 기존 내용을 그대로 보존했어요.

이 변경으로 인증 실패의 원인 자체가 해결되는 것은 아니지만 남아 있는 오류에 프레임워크의 기본 문구가 그대로 노출되는 문제는 줄였어요.

프론트 코드 변경 없이 핵심 흐름을 복구했다

프론트에는 이미 credentials: "include" 설정과 401 재시도 로직이 있었어요. 문제는 브라우저가 쿠키를 전송하지 못하게 막는 백엔드의 쿠키 속성이었어요.

백엔드의 운영 쿠키를 SameSite=None; Secure로 바꾸면서 Chromium 계열에서는 다음 흐름이 다시 동작할 수 있게 됐어요.

프론트 코드는 바꿀 필요가 없었어요. 다만 기존에 발급된 쿠키에는 계속 SameSite=Lax 속성이 남아 있으므로 배포 후 사용자가 한 번 다시 로그인해야 새 속성을 적용한 쿠키를 받을 수 있어요.

검증을 위해 쿠키와 Origin 검사 단위 테스트를 추가했고 전체 유닛 테스트도 모두 통과했어요. 배포된 프론트 번들과 실제 Set-Cookie 헤더도 직접 확인했어요.

남은 한계와 후속 과제

이번 수정은 백엔드만 변경할 수 있는 상황에서 적용한 대응으로, 모든 브라우저를 아우르는 근본적인 해결책은 아니에요.

Safari는 third-party cookie를 기본으로 차단하고 Firefox는 Total Cookie Protection을 통해 쿠키를 top-level site 기준으로 분리해요. iOS 브라우저도 WebKit 정책의 영향을 받아요. 그래서 SameSite=None; Secure를 사용하더라도 Safari, iOS, Firefox에서는 같은 흐름이 계속 실패할 수 있어요.

근본적으로 해결하려면 지원자 프론트를 API와 같은 site에 속하는 별도 서브도메인으로 이전해야 해요. 이전한 뒤에는 인증 쿠키를 다시 SameSite=Lax로 되돌릴 수 있어요.

이번 변경 요청에서는 단위 테스트와 수동 확인을 진행했지만 통합 테스트는 실행하지 않았어요. 다음 단계에서는 브라우저와 배포 도메인 조합별로 로그인, 임시저장, 제출, 토큰 갱신, 로그아웃을 검증하고 그 결과를 기록해야 해요.

이번 장애를 겪으며 cross-site 인증 문제를 CORS나 쿠키 설정 하나만의 문제로 다뤄서는 안 된다는 교훈을 얻었어요. 실제 배포 도메인의 관계와 브라우저의 쿠키 정책을 확인하고, 쿠키 전송을 허용하면서 생기는 CSRF 위험과 브라우저별 한계까지 함께 검토해야 해요.


이 글 고치기
이 글 공유하기:

이전 글
3개월간 배포 실패를 숨긴 초록불: CI/CD 성공 판정 바로잡기