콘텐츠로 이동
Study Note웹 개발 일반

11. 브라우저 보안 — 출처 · CORS · PKCE

“CORS 에러 났어요”는 웹 개발자의 통과의례다 — 브라우저가 왜 막는지 알면 절반은 풀린다

이 장에서 처음 나오는 말6개
출처Origin
프로토콜 + 도메인 + 포트의 묶음 (https://myapp.com:443). 브라우저 보안의 기본 단위 — 셋 중 하나만 달라도 "다른 출처"다.
SOPSame-Origin Policy
"다른 출처의 응답은 스크립트가 읽을 수 없다"는 브라우저의 기본 규칙. 웹 보안 전체가 이 위에 서 있다.
CORSCross-Origin Resource Sharing
SOP를 서버의 허락 하에 푸는 표준. 서버가 응답 헤더로 "이 출처는 읽어도 됨"을 선언한다.
OAuth 2.0
사용자 대신 API에 접근할 권한을 위임하는 프레임워크. 그 자체는 로그인 규격이 아니다.
OIDCOpenID Connect
OAuth 2.0 위에 신원 확인을 얹은 인증 규격. "Google로 로그인"에서 로그인 부분을 담당한다.
PKCEProof Key for Code Exchange
OAuth 흐름에서 인가 코드를 가로채여도 쓸모없게 만드는 장치. "픽시"라고 읽는다.

왜 브라우저에만 이런 규칙이 있는가

섹션 제목: “왜 브라우저에만 이런 규칙이 있는가”

curl이나 서버 코드로는 아무 API나 호출해도 CORS 에러 같은 건 없다. 브라우저만 막는다. 이유는 브라우저의 특수한 사정 하나 때문이다 —

브라우저는 서로 모르는 출처의 코드를 한 사용자 세션 안에서 실행하는 공간이다. 어떤 페이지든 폼·이미지·스크립트 같은 교차 출처 요청을 만들 수 있고, 쿠키의 SameSite 조건이나 fetch의 credentials 설정에 따라 자격 증명이 붙을 수도 있다. 요청을 전부 금지하면 웹이 동작하지 않으므로, 같은 출처 정책(SOP)은 핵심적으로 다른 출처의 응답 내용을 스크립트가 읽지 못하게 막는다.

참고로 교차 출처 fetch의 기본값은 자격 증명을 보내지 않는 credentials: 'same-origin'이다. 쿠키도 SameSite=Lax·Strict라면 교차 사이트 요청에 제한된다. SOP 하나가 모든 공격을 막는다는 뜻은 아니며, 쓰기 요청은 CSRF 방어가 별도로 필요하다. 브라우저 밖(서버 간 통신)에는 “남의 코드 + 내 쿠키”라는 조합이 없으니 이 규칙도 없다.

내 프론트(https://myapp.com)가 내 API 서버(https://api.myapp.com)를 부르는 건 정당한 요청인데, 도메인이 달라서 SOP에 걸린다. 그래서 서버가 허락을 선언하는 표준이 CORS다.

브라우저가 다른 출처의 API에 PATCH를 보내기 전에 OPTIONS 프리플라이트로 허락을 묻고, 서버가 Allow-Origin과 Allow-Methods로 답한 뒤에야 진짜 요청과 응답이 오가는 CORS 순서도
  • 단순한 요청(GET, 일반 POST)은 프리플라이트 없이 바로 가고, 응답 헤더만 검사한다. PATCH/DELETE나 커스텀 헤더가 붙으면 ①의 사전 확인(프리플라이트)이 먼저 나간다
  • 허락의 주체는 응답하는 서버다. Access-Control-Allow-Origin 헤더에 요청한 출처가 없으면 브라우저가 응답을 버린다 — 그게 콘솔의 CORS 에러다
증상실체처방
콘솔에 CORS 에러브라우저 요청 조건과 서버 허용 정책이 불일치요청 URL·메서드·헤더·credentials와 서버 CORS 설정을 함께 확인
로컬에서만 남localhost:5173은 배포 도메인과 다른 출처다dev 서버 프록시 (Vite의 server.proxy) — 브라우저 입장에선 같은 출처가 된다
남의 공개 API에서 남그 API가 브라우저 직접 호출을 허용 안 한 것내 서버(Route Handler)를 경유 — 서버 간에는 CORS가 없다 (+ API 키도 숨겨진다)
OPTIONS 요청이 404서버가 프리플라이트를 처리 안 함프레임워크의 CORS 미들웨어를 쓴다 — 손으로 만들지 않는다

한 가지 흔한 오해 — CORS는 읽기를 막는 장치지, 쓰기를 막는 장치가 아니다. 전통적인 폼 제출은 CORS와 무관하게 다른 출처로 날아간다(응답을 못 읽을 뿐, 요청은 도착한다). “로그인된 사용자의 브라우저를 속여 원치 않는 쓰기를 보내는” 공격이 CSRF(Cross-Site Request Forgery)이고, 쿠키의 SameSite 속성(요즘은 기본 Lax)과 CSRF 토큰이 그 방어선이다. 서버 검증(10장)과 마찬가지로 — 프레임워크·인증 라이브러리가 주는 방어를 끄지 않는 게 첫째 규칙이다.

OAuth와 OIDC — 권한 위임과 로그인을 구분한다

섹션 제목: “OAuth와 OIDC — 권한 위임과 로그인을 구분한다”

OAuth 2.0은 원래 권한 위임 규격이고, 로그인은 그 위의 OpenID Connect(OIDC)가 담당한다. “Google로 로그인”의 목표는 내 서비스가 사용자의 Google 비밀번호를 만지지 않고, Google이 인증한 사용자 신원을 검증 가능한 ID Token으로 받는 것이다.

흐름의 뼈대(인가 코드 방식)는 이렇다 —

사용자가 Google로 로그인을 누르면 앱이 Google 로그인 페이지로 보내고, 동의 뒤 인가 코드를 들고 앱으로 돌아와 그 코드를 ID Token으로 교환하는 순서도

비밀번호는 ②에서 Google 화면에만 입력된다 — 내 앱은 구경도 못 한다. 대신 ③의 인가 코드가 리다이렉트 URL에 실려 돌아오는 구조라, 여기에 약점이 하나 생긴다.

PKCE — 코드를 주워도 쓸 수 없게

섹션 제목: “PKCE — 코드를 주워도 쓸 수 없게”

③의 코드는 브라우저 주소창·히스토리·리다이렉트 경로를 지나간다. 누군가 이 코드를 가로채면(악성 확장, 로그 유출 등) ④를 대신 실행해 토큰을 가져갈 수 있다.

전통적 방어는 ④에서 클라이언트 비밀 키(client secret)를 함께 내는 것이었다 — 서버에서 도는 전통 웹 앱은 비밀을 지킬 수 있으니까. 그런데 SPA와 모바일 앱은 코드가 통째로 사용자 손에 있어서(6장에서 봤듯 번들은 열어 볼 수 있다) 비밀을 숨길 곳이 없다. 이 구멍을 메우는 게 PKCE다.

시작할 때마다 임의의 비밀 문자열을 새로 만들고, 그 지문을 먼저 보낸다.

  1. 앱이 임의 문자열 verifier를 생성하고, 해시한 challenge를 ①에 실어 보낸다
  2. Google은 코드를 발급하며 challenge를 기억해 둔다
  3. ④의 토큰 교환에서 앱이 원본 verifier를 내면, Google이 해시해서 대조한다

코드를 가로챈 공격자는 verifier가 없다 — 해시(challenge)에서 원본을 역산할 수 없으므로 주운 코드는 쓸모가 없다. 매번 새로 만드는 일회용이라 재사용도 안 된다.

  • 브라우저의 특수 사정 — 여러 출처의 코드와 사용자 세션이 한 공간에 있어 경계 규칙이 필요하다
  • SOP: 다른 출처의 응답은 못 읽는다. CORS: 응답하는 서버가 헤더로 허락을 선언한다
  • CORS 에러는 요청 조건과 서버 정책의 불일치다 — 양쪽 설정을 함께 확인한다
  • *는 자격 증명 없는 공개 리소스용이다. 인증 API는 명시적 출처를 쓴다
  • OAuth는 권한 위임, OIDC는 로그인 — 인가 코드 흐름 위에서 함께 쓰인다
  • PKCE는 그 코드를 주워도 쓸모없게 만드는 일회용 자물쇠 — SPA·모바일의 표준이자 요즘 기본값