← Posts

Security · Browser · CORS

CORS - 막은 게 아니라 안 알려주는 것

서버 로그는 200인데 브라우저는 에러를 낸다. CORS는 동일 출처 정책을 뚫는 구멍이 아니라 서버가 예외를 승인하는 절차이고, 애초에 공격을 막는 물건이 아니다.

목차
  1. 서버 로그엔 200인데 브라우저는 에러다
  2. 브라우저가 확인하는 건 응답의 허락 표시다
  3. 흔한 오해: CORS는 서버를 지켜주지 않는다
  4. 단순 요청 - 그냥 보내고 응답에서 확인한다
  5. 프리플라이트 - 먼저 물어보고 나서 보낸다
  6. 쿠키를 실으려면 한 겹이 더 있다
  7. 실무에서: CORS는 필터 자리다
  8. 정리

앞 글에서 브라우저가 다른 출처의 응답을 스크립트에 안 준다고 했다. 그럼 API 서버를 따로 둔 사람들은 어떻게 살고 있나.

서버 로그엔 200인데 브라우저는 에러다

가장 흔한 장면부터 보자. 프런트는 https://shop.example.com, API는 https://api.example.com에 있다.

js
const res = await fetch('https://api.example.com/cart');
const data = await res.json();   // 여기까지 못 온다

콘솔에는 요청이 차단됐다는 메시지가 뜬다. 그런데 API 서버 로그를 열어보면 이렇다.

plaintext
2026-06-15 10:22:31  GET /cart  200  14ms

정상 처리됐다. 조회만 하는 API라 아무 일도 안 일어났지만, 만약 저게 주문 취소 API였다면 주문은 이미 취소됐을 것이다.

여기서 사고를 한 번 뒤집어야 한다. 브라우저가 막은 건 요청이 아니다. 응답을 스크립트에 넘기기를 거부한 것이다.

브라우저가 확인하는 건 응답의 허락 표시다

브라우저는 교차 출처 요청을 보낼 때 요청 헤더에 자기 출처를 적어 넣는다.

http
GET /cart HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com

Origin 헤더가 그것이다. 이건 “나 여기서 왔다”는 신고이고, 브라우저가 붙인다. 스크립트가 바꿀 수 없다.

그러면 서버가 응답에 대답을 적어 보낼 수 있다.

http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example.com
Content-Type: application/json

{"items": [...]}

Access-Control-Allow-Origin에 자기 출처가 적혀 있으면 브라우저는 응답을 스크립트에 넘긴다. 없으면 안 넘긴다. 이 헤더 한 줄이 CORS의 전부라고 해도 크게 틀리지 않는다.

diagramdiagram

헤더가 빠졌을 때 달라지는 건 마지막 두 줄뿐이다. 서버까지의 왕복은 똑같이 일어난다.

CORS(Cross-Origin Resource Sharing)는 그래서 “교차 출처 자원 공유”다. 막는 규격이 아니라, 동일 출처 정책이 이미 막아둔 것을 서버가 골라서 열어주는 규격이다.

흔한 오해: CORS는 서버를 지켜주지 않는다

여기가 이 글의 알맹이다. CORS를 배우고 나면 이런 문장이 자연스럽게 떠오른다.

  • “CORS를 *로 열어두면 아무나 우리 API를 부를 수 있어서 위험하다.”
  • “CORS 설정이 우리 API를 보호하고 있다.”

둘 다 틀렸다. 이유는 간단하다. CORS는 브라우저가 집행하는 규칙이고, 브라우저만 지킨다.

bash
curl https://api.example.com/cart -H 'Cookie: session=...'

curlAccess-Control-Allow-Origin을 읽지도 않는다. 스크립트도 없고 그걸 보호할 사용자도 없으니까. 서버가 CORS 설정을 어떻게 하든 이 명령은 응답을 그대로 받는다. 공격자가 쓰는 도구는 전부 이쪽이다.

그러니 이렇게 정리하는 게 정확하다.

CORS는 공격을 막는 물건이 아니다. 동일 출처 정책이라는 브라우저 제약을 서버가 완화해주는 장치다.

CORS를 넓게 열면 위험해지는 건 서버가 아니라 사용자다. 그것도 특정 조건에서만 그렇다. 아래 두 줄을 같이 두면 위험의 정체가 드러난다.

http
Access-Control-Allow-Origin: *
http
Access-Control-Allow-Origin: https://아무-사이트
Access-Control-Allow-Credentials: true

위쪽은 “쿠키 없이 오는 아무나에게 이 응답을 보여줘도 된다”는 뜻이다. 공개 API면 아무 문제 없다. 위험한 건 아래쪽이다. 요청자의 출처를 그대로 되비추면서 쿠키까지 허용하면, 남의 사이트에 있는 스크립트가 로그인된 사용자의 데이터를 읽어갈 수 있다.

주의

그래서 실무에서 실제로 위험한 설정은 *가 아니라 요청의 Origin 값을 검사 없이 그대로 응답에 복사하면서 Allow-Credentials: true를 같이 주는 것이다. 겉보기엔 “출처를 지정했다”처럼 보여서 리뷰에서도 잘 안 잡힌다. 허용 출처는 목록으로 두고 대조해야 한다.

정리하면, API를 지키는 건 CORS가 아니라 인증·인가다. CORS 설정을 아무리 조여도 인증이 없으면 API는 열려 있고, CORS를 넓게 열어도 인증이 있으면 남이 데이터를 못 가져간다.

단순 요청 - 그냥 보내고 응답에서 확인한다

여기까지가 이른바 단순 요청(simple request) 의 흐름이다. 조건을 만족하면 브라우저는 아무것도 안 물어보고 그냥 보낸 뒤, 응답에서 허락 표시를 확인한다.

대략 이런 조건이다.

항목단순 요청이 되는 범위
메서드GET · HEAD · POST
헤더브라우저가 알아서 붙이는 것 + Accept·Accept-Language·Content-Language·Content-Type 정도
Content-Typeapplication/x-www-form-urlencoded · multipart/form-data · text/plain

세 번째 줄이 눈에 걸릴 것이다. JSON을 보내는 요청(Content-Type: application/json)은 단순 요청이 아니다. 요즘 API 호출이 거의 다 단순 요청 밖으로 나가는 이유가 이것이다.

참고

이 조건은 외울 목록이 아니라 “왜 이 셋만인가”로 이해하는 게 낫다. 저 메서드와 저 Content-Type 조합은 <form> 태그로도 똑같이 보낼 수 있는 요청이다. 폼 제출은 CORS가 생기기 훨씬 전부터 다른 출처로 나갈 수 있었으니, 그걸 이제 와서 막아봐야 새로 얻는 게 없다. 정확한 목록은 Fetch 표준이 관리하고 항목이 조금씩 늘어난 이력이 있어서, 경계가 애매하면 표준을 확인하는 편이 맞다.

그리고 이 문장을 붙잡아두자. 단순 요청은 서버에 이미 도착한다. 응답이 차단돼도 서버는 그 요청을 처리했다. 이 성질이 CSRF 편에서 그대로 문제가 된다.

프리플라이트 - 먼저 물어보고 나서 보낸다

단순 요청 조건을 벗어나면 브라우저의 태도가 바뀐다. 이번엔 본 요청을 보내기 전에 먼저 물어본다. 이게 프리플라이트(preflight)다.

js
await fetch('https://api.example.com/cart/3', {
  method: 'DELETE',
  headers: { 'Content-Type': 'application/json' },
});

DELETE에 JSON이니 단순 요청이 아니다. 브라우저는 DELETE를 보내기 전에 OPTIONS를 하나 먼저 던진다.

diagramdiagram

프리플라이트가 거절되면 본 요청은 아예 안 나간다. 앞에서 “요청은 나간다”고 했던 것과 반대다. 둘의 차이가 바로 여기 있다.

단순 요청프리플라이트가 붙는 요청
순서바로 보낸다OPTIONS로 먼저 묻는다
막혔을 때서버는 이미 처리했다본 요청이 안 나간다
왕복1번2번 (캐시되면 이후 1번)

이 표가 프리플라이트의 존재 이유를 설명한다. DELETE나 JSON 본문 같은 요청은 폼으로는 만들 수 없어서 CORS 이전의 웹에는 없던 요청이다. 그런 요청까지 무턱대고 보내버리면 CORS가 옛날에는 불가능하던 공격을 새로 열어주는 셈이 된다. 그래서 “이런 요청 보내도 되냐”를 먼저 묻는다.

프리플라이트는 요청마다 왕복을 하나 더 붙인다. 응답에 Access-Control-Max-Age를 주면 브라우저가 그 결과를 일정 시간 캐시해서 반복을 줄인다. 다만 상한값은 브라우저마다 다르게 잘리므로, 큰 숫자를 넣었다고 그만큼 유지된다고 단정하지 말 것.

쿠키를 실으려면 한 겹이 더 있다

교차 출처 fetch기본적으로 쿠키를 안 보낸다. 보내려면 양쪽이 다 동의해야 한다.

js
await fetch('https://api.example.com/cart', {
  credentials: 'include',   // 클라이언트 쪽 동의
});
http
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true

여기에 규격이 못을 하나 박아뒀다. Allow-Credentials: true일 때 Allow-Origin*를 쓸 수 없다. 와일드카드로 열어둔 채 남의 로그인 세션을 읽게 두는 조합을 아예 문법으로 금지한 것이다. 그래서 이 경우엔 출처를 정확히 하나 적어야 한다.

출처가 여럿이면 서버가 요청의 Origin을 보고 허용 목록에 있을 때만 그 값을 응답에 적어주는 방식이 된다. 이때 빠뜨리기 쉬운 게 하나 있다.

http
Vary: Origin

응답이 요청 출처에 따라 달라진다는 표시다. 안 붙이면 중간 캐시가 A 출처용 응답을 B 출처에게 그대로 줘서, 어제는 되던 게 오늘은 막히는 재현 안 되는 버그가 된다.

참고

응답 헤더를 스크립트에서 읽는 것도 따로 제한된다. 교차 출처 응답에서는 기본적으로 몇 개만 읽을 수 있고, 커스텀 헤더(페이징 총건수 같은 것)를 읽히려면 서버가 Access-Control-Expose-Headers에 이름을 적어줘야 한다. “서버는 헤더를 보냈는데 클라이언트에서 undefined”의 원인이 대개 이것이다.

실무에서: CORS는 필터 자리다

CORS 응답 헤더는 모든 요청이 똑같이 겪는 일이라 컨트롤러가 붙일 물건이 아니다. 요청이 핸들러에 닿기 전 길목에서 처리한다. 미들웨어 글에서 CORS를 Filter의 예로 든 게 이 자리다.

이 위치 때문에 생기는 함정이 하나 있는데, 이게 실무에서 압도적으로 자주 나온다.

프리플라이트에는 쿠키도 인증 헤더도 안 실린다. 브라우저가 만드는 순수한 확인용 요청이라 그렇다. 그런데 인증 필터가 CORS 필터보다 앞에 서 있으면 어떻게 되나.

diagramdiagram

프리플라이트가 401로 끝나고, 브라우저는 허락을 못 받았으니 본 요청을 안 보낸다. 그리고 콘솔에는 CORS 에러로 뜬다. 인증 문제인데 CORS 문제처럼 보이는 것이다.

그래서 미들웨어 글의 순서 이야기가 여기서 그대로 걸린다. CORS 처리는 인증보다 앞에 세운다. Spring Security를 쓴다면 CORS를 프레임워크에 맡겨 보안 필터 체인 앞쪽에서 처리되게 하는 방식이 일반적이다.

진단 순서를 정해두면 시간을 아낀다.

증상먼저 볼 곳
네트워크 탭에 OPTIONS가 401·403인증 필터가 CORS보다 앞이다
OPTIONS는 200인데 본 요청이 막힘본 응답에 Allow-Origin이 빠졌다
로그인 요청만 쿠키가 안 실림credentials 설정과 Allow-Credentials
어떤 사용자만 간헐적으로 막힘Vary: Origin 누락과 캐시

정리

CORS란동일 출처 정책이 막은 것을 서버가 골라 열어주는 완화 장치
보안 기능인가아니다. 브라우저만 지키는 규칙이고 curl에는 아무 효력이 없다
막는 대상요청이 아니라 응답을 스크립트에 넘기는 것
핵심 헤더요청의 Origin, 응답의 Access-Control-Allow-Origin
단순 요청폼으로도 보낼 수 있던 요청. 그냥 보내고 응답에서 확인
프리플라이트그 밖의 요청. OPTIONS로 먼저 묻고, 거절되면 본 요청이 안 나간다
쿠키를 실으려면credentials + Allow-Credentials: true, 이때 * 금지
진짜 위험한 설정요청 Origin을 검사 없이 되비추면서 자격 증명까지 허용
어디에 두나필터 자리. 인증 필터보다

CORS는 문을 여는 절차지 잠그는 자물쇠가 아니다. 서버를 지키는 건 여전히 인증과 인가다.

그런데 앞 글의 그림에는 가지가 둘 더 있었다. 다음은 정책을 우회하는 쪽이 아니라 정책 안쪽으로 들어오는 쪽이다. 남이 심은 스크립트가 내 페이지에서 실행되면, 그 스크립트의 출처는 내 출처다. 동일 출처 정책도 CORS도 그걸 막을 근거가 없다. 다음 글이 그 이야기다.

관련 글