이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • curl은 성공하는데 브라우저만 실패할 때 어떤 순서로 분리하는가?
  • DevTools에서 cookie, CORS, mixed content, CSP, redirect 문제를 어떻게 증거화하는가?
  • 백엔드/프론트/인프라가 브라우저 보안 장애를 같이 볼 때 어떤 정보를 공유해야 하는가?

개요

브라우저 보안 디버깅은 서버 로그만으로 끝나지 않는다. 브라우저는 서버 응답을 받은 뒤에도 CORS, cookie 정책, mixed content, CSP, redirect, cache, third-party cookie 정책을 적용한다. 백엔드 개발자는 “API는 200인데 프론트에서 실패”라는 보고를 받았을 때 브라우저가 어디에서 막았는지 읽을 수 있어야 한다.

  • Network 탭은 실제 요청과 응답 header를 보여 준다.
  • Console 탭은 CORS, mixed content, CSP 차단 이유를 보여 준다.
  • Application 탭은 쿠키 저장 여부와 속성을 보여 준다.
  • Security 탭은 TLS와 mixed content 판단에 도움을 준다.

원리

브라우저 요청은 서버 간 요청보다 단계가 많다.

JavaScript fetch
  -> browser policy check
  -> optional CORS preflight
  -> network request
  -> response received
  -> CORS exposure check
  -> Set-Cookie storage decision
  -> JavaScript receives or is blocked

서버는 정상 응답했는데 마지막 노출 단계에서 막힐 수 있다. 이때 백엔드 access log만 보면 성공처럼 보인다.

디버깅 순서

  1. Network 탭에서 실제 요청이 있었는지 확인한다.
  2. preflight OPTIONS와 실제 요청을 분리한다.
  3. 요청 header에 Origin, Cookie, custom header가 기대대로 있는지 본다.
  4. 응답 header에 Access-Control-Allow-*, Set-Cookie, Location, Cache-Control이 기대대로 있는지 본다.
  5. Console의 blocked reason을 기록한다.
  6. Application 탭에서 쿠키가 저장됐는지와 rejected reason을 확인한다.
  7. 같은 요청을 curl로 재현해 서버 응답과 브라우저 정책 문제를 분리한다.

흔한 증상별 판단

증상우선 확인
curl 성공, fetch 실패CORS, mixed content, CSP, credential 설정
login 200 이후 /me 401Set-Cookie 저장 여부, 다음 요청의 Cookie, SameSite/Secure/Domain
preflight 401Spring Security/gateway의 OPTIONS 허용
응답 body가 JS에서 안 보임Access-Control-Allow-Origin, credential, exposed headers
local만 성공localhost cookie/Secure 차이, origin allowlist
특정 브라우저만 실패third-party cookie 차단, 확장 프로그램, 캐시, 브라우저 버전

코드와 명령으로 확인하기

프론트 요청을 최소 재현한다.

const res = await fetch("https://api.example.com/me", {
  method: "GET",
  credentials: "include",
  headers: {
    "X-Request-ID": crypto.randomUUID()
  }
});
 
console.log(res.status, [...res.headers.entries()]);

서버 응답 header를 CLI로 확인한다.

curl -i https://api.example.com/me \
  -H 'Origin: https://app.example.com' \
  -H 'Cookie: sid=test'

preflight를 따로 확인한다.

curl -i -X OPTIONS https://api.example.com/me \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: GET' \
  -H 'Access-Control-Request-Headers: x-request-id'

브라우저가 blocked한 요청은 HAR 파일로 저장해 프론트, 백엔드, 인프라가 같은 증거를 볼 수 있다.

Rare Case

  • 브라우저 확장 프로그램이 request header를 바꾸거나 요청을 차단한다.
  • HSTS가 남아 있어 http:// 테스트가 자동으로 HTTPS로 바뀐다.
  • Service Worker가 오래된 API 응답을 반환한다.
  • CDN cache가 Set-Cookie 포함 응답을 잘못 재사용한다.
  • clock skew로 cookie 만료나 인증 token 검증이 어긋난다.
  • Safari나 embedded WebView의 third-party cookie 정책이 Chrome과 다르다.

인프라 협업 포인트

  • HAR, request id, edge request id, 발생 시각, 브라우저/OS 버전, origin URL을 함께 공유한다.
  • gateway가 CORS를 처리하는지 app이 처리하는지 소유권을 정한다.
  • CDN/WAF가 Origin, Cookie, Set-Cookie, Authorization, custom header를 제거하거나 cache key에서 누락하는지 확인한다.
  • TLS termination과 redirect 정책이 cookie Secure, CORS origin, OAuth callback URL과 맞는지 같이 본다.

실전 팁

  • 브라우저 장애 보고 양식을 만들 때 “Network 탭 screenshot”보다 HAR와 Console error text를 받는 편이 재현에 유리하다.
  • “CORS 에러”는 브라우저가 보여 주는 마지막 증상일 수 있다. 실제 원인은 302, 401, preflight 차단, TLS redirect일 수 있다.
  • 응답의 custom header를 프론트에서 읽어야 한다면 Access-Control-Expose-Headers가 필요하다.
  • 인증 관련 응답은 cache를 매우 보수적으로 잡는다. Cache-Control: no-store가 필요한 응답과 public cache 가능한 응답을 분리한다.
  • 개인 프로젝트도 production origin과 localhost origin을 분리해서 설정하고, wildcard로 영구 우회하지 않는다.

위험 신호!

  • 서버 로그의 200만 보고 브라우저 실패를 프론트 문제로 넘긴다.
  • preflight와 실제 요청을 구분하지 않고 같은 API 실패로 기록한다.
  • login 응답의 Set-Cookie와 다음 요청의 Cookie를 비교하지 않는다.
  • HAR 없이 스크린샷만으로 CORS/쿠키 장애를 추적한다.

확인 질문

확인 질문

  • curl은 성공하는데 브라우저 fetch만 실패하면 무엇을 먼저 의심해야 하는가?
    • CORS, cookie 전송 조건, mixed content, CSP, credential 설정처럼 브라우저가 추가로 적용하는 정책을 의심해야 한다.
  • preflight와 실제 요청을 분리해서 봐야 하는 이유는 무엇인가?
    • preflight가 실패하면 실제 요청이 서버에 가지 않으므로 앱 로그만으로는 원인을 찾기 어렵기 때문이다.
  • 브라우저가 Set-Cookie를 저장하지 않은 이유는 어디에서 확인하는가?
    • DevTools Network/Application 탭의 cookie warning 또는 rejected reason에서 확인한다.

참고 문서