들어가며 — curl은 되는데 브라우저만 막힌다
콘솔에 이 문장이 떴다고 해 봅시다.
Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
가장 먼저 하는 일은 보통 같은 요청을 터미널에서 재현해 보는 것입니다.
curl -i -X GET 'https://api.example.com/v1/orders' \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer eyJhbGciOi...'
HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 1842
date: Sun, 26 Jul 2026 04:11:32 GMT
x-request-id: 9f1c2a44-3b8e-4d1a-9c77-2f0a1b6d4e55
{"items":[{"id":"ord_8812","total":49000}, ...]}
200입니다. 서버는 요청을 받았고, 인증을 통과시켰고, DB를 조회했고, JSON을 돌려줬습니다. 데이터가 없어서 실패한 게 아닙니다. 응답에 딱 한 줄, access-control-allow-origin이 없을 뿐입니다.
이 한 줄의 차이가 CORS의 전부를 설명합니다. 그리고 여기서 대부분의 사람이 첫 번째 잘못된 결론에 도달합니다. "프런트엔드 문제니까 프런트엔드에서 고치자"는 결론입니다. 프런트엔드에서 고칠 수 있는 것은 없습니다. 서버가 허락을 표시하지 않았기 때문에 브라우저가 막은 것이고, 허락을 표시할 수 있는 주체는 서버뿐입니다.
CORS는 서버 보안이 아니라 브라우저가 강제하는 완화 정책이다
동일 출처 정책은 브라우저의 기본값입니다. 스킴, 호스트, 포트가 모두 같아야 같은 출처이고, 다른 출처의 응답은 스크립트가 읽을 수 없습니다. https://app.example.com과 https://api.example.com은 다른 출처이고, https://app.example.com과 http://app.example.com도, https://app.example.com과 https://app.example.com:8443도 다른 출처입니다.
이 정책이 없으면 어떤 일이 벌어지는지 생각해 보면 존재 이유가 명확해집니다. 악성 사이트가 열려 있는 탭에서 https://mail.example.com/inbox를 fetch로 가져와 읽을 수 있다면, 브라우저에 남아 있는 쿠키가 자동으로 실려서 로그인된 상태로 메일함 전체를 긁어갈 수 있습니다. 동일 출처 정책은 이것을 막습니다.
CORS는 이 정책을 푸는 장치입니다. 조이는 장치가 아닙니다. 서버가 "이 오리진에서 온 스크립트가 내 응답을 읽어도 된다"고 응답 헤더로 선언하면, 브라우저가 예외를 허용합니다. 여기서 세 가지 사실이 따라 나옵니다.
첫째, 강제하는 주체는 브라우저입니다. curl, Postman, 서버 사이드 fetch, 모바일 앱의 HTTP 클라이언트는 동일 출처 정책을 구현하지 않으므로 CORS와 무관합니다. "CORS로 API를 보호한다"는 말은 성립하지 않습니다. 진짜 공격자는 브라우저를 쓰지 않습니다.
둘째, 판단 대상은 응답을 읽는 행위이지 요청을 보내는 행위가 아닙니다. 프리플라이트가 붙지 않는 요청은 실제로 서버에 도달해서 실행됩니다. 브라우저는 그 응답을 스크립트에게 넘겨주지 않을 뿐입니다. 이 사실은 뒤에서 CSRF를 이야기할 때 결정적입니다.
셋째, 고칠 곳은 항상 응답 헤더를 만드는 쪽입니다. 그 쪽이 우리 서버면 우리가 고치고, 남의 API면 그쪽에 요청하거나 우리 서버를 경유해야 합니다. 브라우저 설정을 바꾸는 것은 내 브라우저에서만 유효한 자기기만입니다.
프리플라이트가 발생하는 정확한 조건
브라우저는 모든 교차 출처 요청에 OPTIONS를 먼저 보내지 않습니다. HTML 폼으로 이미 오래전부터 보낼 수 있었던 형태의 요청은 그대로 보냅니다. 이것을 단순 요청이라고 부르며, 조건은 다음 세 가지를 모두 만족하는 경우입니다.
메서드가 GET, HEAD, POST 중 하나여야 합니다. PUT, PATCH, DELETE는 무조건 프리플라이트를 유발합니다.
수동으로 설정한 헤더가 허용 목록 안에만 있어야 합니다. 목록은 Accept, Accept-Language, Content-Language, Content-Type, Range 정도입니다. Authorization을 붙이는 순간 프리플라이트가 생깁니다. X-Requested-With, X-Trace-Id 같은 커스텀 헤더도 마찬가지입니다. 이것이 실무에서 가장 흔한 발생 원인입니다.
Content-Type 값이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 합니다. application/json은 이 목록에 없습니다. 그래서 JSON을 POST하는 거의 모든 현대 API 호출은 프리플라이트를 겪습니다.
부가적으로 XMLHttpRequestUpload에 이벤트 리스너가 붙어 있거나, 요청 본문으로 ReadableStream을 쓰면 역시 프리플라이트가 발생합니다.
실제 프리플라이트는 이렇게 생겼습니다.
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
프리플라이트에는 본문이 없고, 쿠키도 실리지 않으며, 서버의 인증을 통과할 필요도 없습니다. 서버가 돌려줘야 하는 것은 이런 응답입니다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
여기서 자주 깨지는 지점이 두 개 있습니다. 하나는 인증 미들웨어가 OPTIONS 요청에도 걸려서 401을 돌려주는 경우입니다. 프리플라이트는 2xx가 아니면 실패로 처리되므로, CORS 처리는 인증 미들웨어보다 앞에 놓여야 합니다. 다른 하나는 리다이렉트입니다. 프리플라이트 응답에 301이나 308이 오면 브라우저는 따라가지 않고 그대로 실패시킵니다. HTTP에서 HTTPS로 보내는 리다이렉트, 끝 슬래시를 붙이는 리다이렉트가 여기에 걸립니다.
Access-Control-Max-Age는 프리플라이트 결과를 브라우저가 캐시하는 시간입니다. 다만 상한이 있습니다. 크롬은 7200초에서 자르고, 사파리는 그보다 훨씬 짧습니다. 86400을 적어 놓고 하루 동안 OPTIONS가 안 갈 것이라 기대하면 어긋납니다.
응답 헤더의 역할과 자주 틀리는 조합
Access-Control-Allow-Origin은 값 하나만 가질 수 있습니다. 콤마로 여러 오리진을 나열하는 것은 유효하지 않습니다. 여러 오리진을 허용하려면 서버가 요청의 Origin 헤더를 보고 허용 목록에 있을 때만 그 값을 그대로 되돌려줘야 합니다.
Access-Control-Expose-Headers는 스크립트가 읽을 수 있는 응답 헤더를 늘립니다. 기본적으로 읽을 수 있는 것은 Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma 일곱 개뿐입니다. 페이지네이션 총 개수를 X-Total-Count로 내려보내는데 프런트엔드에서 null이 나온다면 거의 항상 이 헤더를 빠뜨린 것입니다. 서버 로그에는 헤더가 잘 찍히는데 클라이언트에서만 안 보이는 형태로 나타나서 원인을 찾기가 유난히 어렵습니다.
가장 자주 틀리는 조합은 자격 증명과 와일드카드입니다. credentials: 'include'로 쿠키를 실어 보내는 요청에는 다음 규칙이 적용됩니다. Access-Control-Allow-Origin이 별표면 안 되고, Access-Control-Allow-Headers와 Access-Control-Allow-Methods의 별표도 무효가 되며, Access-Control-Expose-Headers의 별표도 무효입니다. 전부 명시적으로 나열해야 합니다. 이 규칙에는 이유가 있습니다. 별표를 허용하면 아무 사이트나 사용자의 쿠키로 인증된 응답을 읽을 수 있게 되어 동일 출처 정책이 사라지는 것과 같아집니다.
그래서 다음 코드는 위험합니다.
// 하지 마세요 — 모든 오리진에게 인증된 응답을 읽게 허용합니다
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*')
res.setHeader('Access-Control-Allow-Credentials', 'true')
next()
})
Origin을 그대로 반사하면 와일드카드 금지 규칙을 형식적으로만 피해 갈 뿐, 실질적으로는 모든 오리진을 허용한 것입니다. 공격자 사이트에서 fetch를 날리면 사용자의 세션 쿠키가 실리고, 응답이 그대로 읽힙니다. 반드시 허용 목록으로 검사해야 합니다.
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])
app.use((req, res, next) => {
const origin = req.headers.origin
// 허용되지 않은 오리진이어도 Vary는 항상 붙입니다
res.setHeader('Vary', 'Origin')
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, RateLimit-Remaining')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
res.setHeader('Access-Control-Max-Age', '7200')
return res.status(204).end() // 인증 미들웨어 앞에서 끝냅니다
}
next()
})
허용 목록을 정규식으로 만들 때는 점을 이스케이프하고 문자열 끝을 고정해야 합니다. 서브도메인을 통째로 허용하려고 대충 쓴 패턴은 https://example.com.attacker.io 같은 값을 통과시킵니다. 실제로 이 실수로 인한 계정 탈취 사례가 여러 번 공개됐습니다. 가능하면 정규식 대신 문자열 집합으로 비교하는 편이 안전합니다.
Origin: null도 허용 목록에 넣으면 안 됩니다. sandbox 속성이 붙은 iframe, 로컬 파일, 일부 리다이렉트 상황에서 붙는 값인데, 공격자가 자기 페이지에 sandbox iframe을 하나 띄우면 언제든 만들어 낼 수 있습니다.
Vary를 빠뜨리면 캐시가 오염된다
Origin에 따라 응답 헤더가 달라지는데 Vary: Origin이 없으면, 중간의 CDN이나 리버스 프록시는 URL만 보고 응답을 재사용합니다. 그러면 이런 사고가 납니다. admin 오리진에서 온 요청이 만든 응답이 캐시에 들어가고, 다음에 app 오리진에서 같은 URL을 요청하면 Access-Control-Allow-Origin: https://admin.example.com이 담긴 응답이 나갑니다. app에서는 CORS 에러가 나고, 캐시가 만료될 때까지 계속됩니다.
증상이 특히 고약합니다. 재현이 안 되고, 새로고침하면 되기도 하고, 특정 리전 사용자에게만 발생합니다. 반대 방향의 사고도 있습니다. 서버 사이드에서 Origin 없이 요청한 응답이 캐시되면 CORS 헤더가 아예 없는 응답이 저장되고, 그 뒤 모든 브라우저 요청이 막힙니다.
그래서 Origin을 반사하는 서버는 조건과 무관하게 항상 Vary: Origin을 붙여야 합니다. 허용하지 않은 오리진에 대해서도 붙여야 합니다.
에러 메시지별 원인 해독
브라우저 콘솔 메시지는 생각보다 정확하게 원인을 지목합니다. 자주 보는 것들입니다.
| 콘솔 메시지의 핵심 문구 | 실제 원인 | 고칠 곳 |
|---|---|---|
| No Access-Control-Allow-Origin header is present | 서버가 헤더를 안 보냄. 5xx로 죽어서 CORS 미들웨어를 못 탄 경우 포함 | 서버 응답 헤더. 먼저 curl로 상태 코드부터 확인 |
| Response to preflight request does not have HTTP ok status | OPTIONS가 401, 404, 405, 500을 반환 | CORS 처리를 인증 미들웨어보다 앞으로. OPTIONS 라우트 등록 |
| Redirect is not allowed for a preflight request | OPTIONS 응답이 301 또는 308 | HTTPS 강제 리다이렉트, 끝 슬래시 정규화가 OPTIONS를 안 타게 |
| Request header field authorization is not allowed | Access-Control-Allow-Headers에 해당 헤더가 빠짐 | 프리플라이트 응답의 허용 헤더 목록 |
| Method PATCH is not allowed by Access-Control-Allow-Methods | 허용 메서드 목록 누락 | 프리플라이트 응답의 허용 메서드 목록 |
| must not be the wildcard when credentials mode is include | 별표를 쓰면서 쿠키를 보냄 | 오리진을 명시값으로. 허용 목록 검사 추가 |
| contains multiple values, but only one is allowed | 프록시와 애플리케이션이 각각 헤더를 붙임 | 한 곳에서만 붙이도록 정리. 보통 nginx와 앱 양쪽에 설정됨 |
| Origin null is not allowed | file 프로토콜, sandbox iframe, 리다이렉트 이후 요청 | 로컬 개발 서버 사용. null을 허용 목록에 넣지 말 것 |
목록 맨 위의 것이 가장 흔하면서 가장 오해를 많이 삽니다. 이 메시지는 "서버가 CORS를 설정 안 했다"는 뜻일 수도 있지만, "서버가 500으로 죽어서 헤더를 붙이는 미들웨어까지 도달하지 못했다"는 뜻인 경우가 대단히 많습니다. CORS 에러가 뜨면 반드시 네트워크 탭에서 실제 상태 코드를 먼저 보거나 curl로 확인해야 합니다. 500을 CORS 문제로 착각하고 몇 시간을 헤매는 일이 흔합니다.
프록시 우회 — 정당한 경우와 아닌 경우
같은 오리진으로 만들면 CORS는 아예 발생하지 않습니다. 그래서 프록시는 언제나 통합니다. 문제는 언제 그것이 설계이고 언제 회피인가입니다.
정당한 경우가 있습니다. 첫째, 서드파티 API가 CORS 헤더를 안 보내고 우리가 그 서버를 고칠 수 없을 때입니다. 둘째, API 키를 숨겨야 할 때입니다. 브라우저로 내려간 키는 공개된 키이므로, 이 경우 서버 경유는 우회가 아니라 유일하게 옳은 구조입니다. 셋째, 여러 백엔드를 하나의 오리진 아래 묶는 게이트웨이나 BFF를 이미 운영하고 있을 때입니다. 넷째, 개발 환경에서 dev 서버가 API를 프록시하는 경우입니다.
// vite.config.js — 개발 중에는 같은 오리진으로 만들어 버립니다
export default {
server: {
proxy: {
'/api': {
target: 'https://api-dev.example.com',
changeOrigin: true,
},
},
},
}
정당하지 않은 경우도 분명합니다. 우리가 통제하는 백엔드인데 헤더 세 줄 넣기가 귀찮아서 프록시를 세우는 것은 인프라 한 겹과 지연 시간을 영구히 추가하는 선택입니다. 공개된 CORS 프록시 서비스를 프로덕션에서 쓰는 것은 사용자의 토큰과 데이터를 제3자 서버로 흘려보내는 것이고, 그 서버가 죽으면 우리 서비스도 같이 죽습니다.
mode: 'no-cors'도 해법이 아닙니다. 에러는 사라지지만 opaque 응답이 돌아와서 상태 코드도 본문도 읽을 수 없습니다. 이미지나 스크립트를 side effect로 로드하는 경우가 아니면 쓸모가 없는데, 에러가 없어졌다는 이유로 고쳤다고 착각하기 쉽습니다.
브라우저 보안을 끄라는 조언
검색하면 상위에 반드시 나오는 조언이 있습니다. --disable-web-security 플래그로 크롬을 띄우라는 것입니다. 세 가지 이유로 나쁩니다.
그 프로필의 모든 탭에서 동일 출처 정책이 사라집니다. 개발 중에 열어 둔 다른 탭들이 전부 무방비가 됩니다. 습관이 되면 평소 쓰는 브라우저에서도 그 플래그를 켜게 됩니다.
프로덕션에 존재하지 않는 환경에서 개발하게 됩니다. 자격 증명과 와일드카드 조합, Vary 누락, 노출 헤더 누락 같은 문제가 전부 가려졌다가 스테이징이나 프로덕션에서 한꺼번에 터집니다.
문제를 미룰 뿐 아무것도 해결하지 않습니다. 어차피 배포 전에 서버 헤더를 고쳐야 하고, 그때 고치면 그동안 쌓인 오해까지 같이 풀어야 합니다.
대안은 간단합니다. dev 서버 프록시를 쓰거나, 개발용 오리진을 서버의 허용 목록에 추가하면 됩니다. 후자가 더 낫습니다. 프로덕션과 같은 경로로 검증되기 때문입니다.
CORS는 CSRF를 막아 주지 않는다
가장 위험한 오해입니다. "CORS를 설정했으니 다른 사이트에서 우리 API를 못 부른다"는 말은 틀렸습니다. CORS는 응답을 읽는 것을 막지, 요청이 실행되는 것을 막지 않습니다.
공격자 페이지에 이런 폼이 있다고 해 봅시다.
<!-- evil.example.com — CORS는 이 요청을 전혀 막지 않습니다 -->
<form action="https://bank.example.com/transfer" method="POST">
<input name="to" value="attacker" />
<input name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit()
</script>
폼 전송은 Content-Type이 application/x-www-form-urlencoded인 POST이므로 단순 요청입니다. 프리플라이트가 없습니다. 브라우저는 bank.example.com의 쿠키를 실어서 요청을 보내고, 서버는 로그인된 사용자의 요청으로 처리해서 송금을 실행합니다. 공격자는 응답을 읽지 못하지만 읽을 필요가 없습니다. 이미 돈이 옮겨졌습니다.
CSRF를 막는 것은 다른 장치들입니다.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Lax는 교차 사이트에서 온 POST에 쿠키를 싣지 않습니다. 최신 브라우저의 기본값이기도 해서 상당수의 고전적 CSRF가 이미 막힙니다. 다만 사이트 단위라는 점을 알아야 합니다. app.example.com과 api.example.com은 다른 오리진이지만 같은 사이트이므로 SameSite는 이 둘 사이를 구분하지 않습니다. 서브도메인 하나가 뚫리면 방어가 사라집니다.
그래서 상태를 바꾸는 요청에는 CSRF 토큰을 함께 씁니다. 서버가 발급한 값을 요청 본문이나 커스텀 헤더에 실어 보내게 하고 서버가 대조하는 방식입니다. 커스텀 헤더를 요구하는 것 자체가 프리플라이트를 강제하므로 부수적인 방어가 되기도 하지만, 이것만 믿기에는 근거가 약합니다. JSON만 받겠다고 선언하고 폼 계열 Content-Type을 거부하는 것도 같은 성격의 보조 수단입니다.
정리하면 CORS와 CSRF는 방향이 반대인 문제입니다. CORS는 남의 데이터를 읽어 가는 것을, CSRF 방어는 남의 이름으로 쓰는 것을 막습니다. 한쪽을 설정했다고 다른 쪽이 해결되지 않습니다.
마치며 — 브라우저가 알려 준 것은 서버의 문제다
CORS 에러를 만났을 때 순서는 이렇습니다. curl로 같은 요청을 보내 실제 상태 코드와 응답 헤더를 확인합니다. 500이면 CORS 문제가 아니라 서버 에러입니다. 200인데 헤더가 없으면 서버의 CORS 설정 문제입니다. 프리플라이트가 도는 요청이면 OPTIONS 응답을 따로 확인합니다. 그다음 콘솔 메시지의 핵심 문구를 앞의 표에서 찾습니다.
CORS 에러는 브라우저가 서버 설정의 결함을 알려 주는 신호이고, 신호를 끄는 것은 결함을 고치는 것과 다릅니다. 프런트엔드 코드, 브라우저 플래그, 확장 프로그램에서 답을 찾는 시간이 길어질수록 정답에서 멀어집니다. 고칠 곳은 응답 헤더를 만드는 서버 한 곳뿐입니다.
현재 단락 (1/121)
콘솔에 이 문장이 떴다고 해 봅시다.