이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
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만 보면 성공처럼 보인다.
디버깅 순서
- Network 탭에서 실제 요청이 있었는지 확인한다.
- preflight
OPTIONS와 실제 요청을 분리한다. - 요청 header에
Origin,Cookie, custom header가 기대대로 있는지 본다. - 응답 header에
Access-Control-Allow-*,Set-Cookie,Location,Cache-Control이 기대대로 있는지 본다. - Console의 blocked reason을 기록한다.
- Application 탭에서 쿠키가 저장됐는지와 rejected reason을 확인한다.
- 같은 요청을
curl로 재현해 서버 응답과 브라우저 정책 문제를 분리한다.
흔한 증상별 판단
| 증상 | 우선 확인 |
|---|---|
curl 성공, fetch 실패 | CORS, mixed content, CSP, credential 설정 |
login 200 이후 /me 401 | Set-Cookie 저장 여부, 다음 요청의 Cookie, SameSite/Secure/Domain |
| preflight 401 | Spring 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에서 확인한다.