CORS 에러 해결: 'No Access-Control-Allow-Origin' 원인과 프리플라이트·credentials 설정

이 글의 핵심

CORS 에러는 서버가 브라우저에게 교차 출처 응답을 읽어도 된다고 알려 주지 않았다는 뜻입니다. 콘솔 메시지를 정확히 읽고, Network 탭에서 상태 코드와 프리플라이트 응답을 확인하면 대부분 원인을 바로 찾을 수 있습니다.

CORS란

CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 서버가 “이 출처의 스크립트가 응답을 읽어도 된다”고 브라우저에 알려 주는 HTTP 헤더 규약입니다. 서버가 허용하지 않으면 브라우저는 교차 출처 응답을 자바스크립트에 넘기지 않고, 콘솔에 CORS 정책 위반 에러를 출력합니다. 요청 자체를 막는 경우와 응답만 가리는 경우가 나뉘는데, 이 차이가 뒤에서 설명할 프리플라이트와 CSRF 문제의 핵심입니다.

Origin이란

출처(origin)는 스킴, 호스트, 포트의 조합입니다.

https://example.com:443/api
└── https://example.com:443  ← origin (기본 포트 443은 보통 생략)

다음 쌍은 모두 서로 다른 출처로 취급됩니다.

  • http://localhost:3000과 http://localhost:8000 (포트가 다름)
  • https://app.example.com과 https://api.example.com (호스트가 다름)
  • http://example.com과 https://example.com (스킴이 다름)

프런트엔드 개발 서버(localhost:3000)에서 백엔드(localhost:8000)를 호출할 때 CORS 에러를 처음 만나는 것도 포트가 달라 다른 출처가 되기 때문입니다.


동일 출처 정책(SOP)과 CORS의 관계

동일 출처 정책은 한 문서의 스크립트가 다른 출처의 리소스에 마음대로 접근하지 못하게 하는 브라우저의 기본 규칙입니다. 다른 창이나 iframe의 DOM에 접근하는 것이 제한되고, fetch나 XMLHttpRequest로 받은 교차 출처 응답의 본문을 스크립트가 읽을 수 없습니다. CORS는 이 제한을 서버가 명시적으로 완화하는 장치이고, 기본값은 언제나 “읽기 불가”입니다.

CORS 검사는 브라우저가 하는 일이므로 Node.js, curl, 백엔드 간 HTTP·gRPC 호출에는 적용되지 않습니다. 같은 출처 요청도 CORS 헤더 없이 응답을 읽을 수 있습니다. <img>나 <script> 태그로 다른 출처의 리소스를 불러오는 것도 기본적으로 허용되며, 이 경우는 캔버스에서 픽셀 데이터를 읽는 것처럼 스크립트가 내용을 꺼내려 할 때 별도 제약이 걸립니다. 그래서 “curl로는 되는데 브라우저에서만 안 된다”는 증상은 대부분 CORS 헤더나 쿠키의 SameSite 속성 문제입니다.

CORS는 CSRF 방어가 아닙니다

가장 자주 생기는 오해입니다. CORS는 응답을 읽는 것을 막을 뿐, 요청이 서버에 도달해 실행되는 것을 막지 않습니다. 단순 요청 조건을 만족하는 요청(예: application/x-www-form-urlencoded 본문의 POST)은 프리플라이트 없이 바로 서버로 가고, 서버는 그 요청을 처리해 DB를 바꾼 뒤 응답을 돌려줍니다. 브라우저는 그 응답을 공격자 페이지의 스크립트에 보여 주지 않을 뿐, 부작용은 이미 일어났습니다. 그래서 “CORS 허용 목록을 좁게 설정했으니 다른 사이트에서 우리 API를 호출할 수 없다”는 생각은 틀렸고, 쿠키 기반 인증을 쓰는 상태 변경 API는 SameSite 쿠키, CSRF 토큰, Origin 헤더 검증 같은 별도 방어가 필요합니다. 반대로 CORS 설정을 느슨하게 하면 이 방어가 무너지는 경우가 있는데, 뒤에서 볼 “Origin 반사” 설정이 대표적입니다.


프리플라이트(preflight) 메커니즘

교차 출처 요청이 단순 요청(simple request)이 아니면, 브라우저는 실제 요청을 보내기 전에 OPTIONS 메서드로 프리플라이트 요청을 보내 서버가 허용하는 범위를 묻습니다. 허용된다는 응답을 받은 다음에야 실제 메서드(POST, PUT 등)를 전송합니다.

단순 요청의 조건

다음을 모두 만족하면 프리플라이트 없이 바로 본 요청이 갑니다.

  • 메서드가 GET, HEAD, POST 중 하나입니다.
  • 직접 설정한 헤더가 Accept, Accept-Language, Content-Language, Content-Type 같은 허용 목록 안에 있습니다. Authorization은 이 목록에 없습니다.
  • Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나입니다.

JSON 본문을 보내면서 Content-Type: application/json을 붙이면 세 번째 조건을 벗어나므로 프리플라이트가 발생합니다. GET 요청에 Authorization 헤더만 추가해도 마찬가지입니다.

프리플라이트 요청과 응답

브라우저가 보내는 OPTIONS 요청에는 다음 헤더가 실립니다.

  • Origin: 요청 페이지의 출처
  • Access-Control-Request-Method: 실제로 보내려는 메서드(예: PUT)
  • Access-Control-Request-Headers: 실제로 보내려는 비단순 헤더 목록(예: authorization,x-request-id)

서버는 응답으로 허용 범위를 돌려줍니다.

헤더의미
Access-Control-Allow-Origin허용 출처. *도 가능하지만 credentials 요청에서는 쓸 수 없음
Access-Control-Allow-Methods허용 HTTP 메서드
Access-Control-Allow-Headers허용 요청 헤더
Access-Control-Max-Age프리플라이트 결과를 캐시할 시간(초)

프리플라이트가 실패하면 본 요청은 아예 전송되지 않습니다. 단순 요청은 서버까지 가서 처리된 뒤 응답만 가려지지만, 프리플라이트가 필요한 요청은 서버에 도달하기 전에 멈춘다는 점이 다릅니다.

Access-Control-Max-Age는 무한정 늘릴 수 없습니다. 브라우저마다 상한이 있어서 Chromium은 2시간(7200초), Firefox는 24시간으로 잘라 적용하므로, 86400을 줘도 Chrome에서는 2시간마다 프리플라이트가 다시 나갑니다. 또 개발자 도구에서 “Disable cache”를 켜 두면 프리플라이트 캐시도 무시되어 매 요청마다 OPTIONS가 보이는데, 이를 설정 문제로 오해하는 경우가 많습니다.


CORS 에러 메시지별 원인과 해결

CORS 에러를 만나면 설정을 고치기 전에 개발자 도구 Network 탭에서 두 가지를 먼저 확인합니다. 하나는 실패한 요청의 상태 코드이고, 다른 하나는 그 앞에 OPTIONS 요청(프리플라이트)이 있었는지, 있었다면 그 응답이 어땠는지입니다. 아래 메시지는 Chrome 콘솔 기준이며, 앞부분은 모두 같은 형식입니다.

Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: ...

No ‘Access-Control-Allow-Origin’ header is present

Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

가장 흔한 메시지입니다. 응답에 Access-Control-Allow-Origin 헤더가 없다는 뜻이며, CORS 미들웨어를 적용하지 않았거나, 허용 목록에 이 출처가 없거나, 역프록시가 헤더를 제거한 경우입니다.

이 메시지가 실제로는 서버 에러인 경우가 많다는 점을 꼭 기억해야 합니다. 서버가 500을 내거나 게이트웨이가 502·504를 돌려주면, 그 에러 응답은 CORS 미들웨어를 거치지 않고 만들어지는 경우가 많아 CORS 헤더가 붙어 있지 않습니다. 브라우저는 헤더가 없다는 이유로 응답을 가리고 CORS 에러만 보여 주므로, CORS 설정을 한참 고치다가 나중에야 서버가 죽어 있었다는 것을 알게 됩니다. Network 탭의 상태 코드와 서버 로그를 먼저 보는 것이 가장 빠른 진단 순서입니다. Firefox는 같은 상황에서 상태 코드를 메시지에 함께 보여 주어 구분이 더 쉽습니다.

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8000/api/users. (Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 500.

에러 응답에도 CORS 헤더가 붙도록 CORS 처리를 에러 핸들러보다 바깥(또는 역프록시)에 두면 프런트엔드에서 실제 상태 코드를 읽을 수 있습니다.

메시지 뒷부분의 mode: 'no-cors' 안내는 해결책이 아닙니다. no-cors로 요청하면 에러는 사라지지만 응답이 불투명(opaque) 응답이 되어 상태 코드도 본문도 읽을 수 없습니다. JSON API를 호출하는 상황에서는 쓸모가 없습니다.

Response to preflight request doesn’t pass access control check

Access to fetch at 'http://localhost:8000/api/users/1' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to fetch at 'http://localhost:8000/api/users/1' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

프리플라이트 단계에서 실패한 경우입니다. 두 번째 메시지는 OPTIONS 응답의 상태 코드가 2xx가 아니라는 뜻으로, 헤더가 맞더라도 실패합니다. 원인은 대체로 다음 중 하나입니다.

  • 라우터에 OPTIONS 핸들러가 없어 404가 나옵니다. 프레임워크의 CORS 미들웨어가 전역으로 OPTIONS를 처리하는지 확인합니다.
  • 인증 미들웨어가 OPTIONS를 401·403으로 거절합니다. 프리플라이트 요청에는 쿠키도 Authorization 헤더도 실리지 않으므로, 인증 미들웨어가 CORS 미들웨어보다 앞에 있으면 로그인한 사용자도 전부 CORS 에러를 봅니다. CORS 처리를 인증보다 앞에 두거나 OPTIONS를 인증 검사에서 제외합니다.
  • 역프록시가 OPTIONS에 405를 돌려주거나 앱보다 먼저 응답하면서 CORS 헤더를 빠뜨립니다. Nginx나 API Gateway에서 OPTIONS를 204로 끝낸다면 앱과 같은 CORS 헤더를 붙여야 합니다.
  • HTTP에서 HTTPS로, 또는 경로 끝 슬래시를 붙이는 301·302 리다이렉트가 일어납니다. 이 경우 메시지는 Redirect is not allowed for a preflight request.로 나오며, 요청 URL을 리다이렉트 없는 최종 주소로 바꾸면 됩니다.

Request header field … is not allowed

Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
Access to fetch at 'http://localhost:8000/api/users/1' from origin 'http://localhost:3000' has been blocked by CORS policy: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

프리플라이트의 Access-Control-Request-Headers·Access-Control-Request-Method에 있는 값이 서버의 Access-Control-Allow-Headers·Access-Control-Allow-Methods에 없는 경우입니다. 메시지에 나온 헤더나 메서드를 허용 목록에 추가합니다. Access-Control-Allow-Headers: *는 credentials가 없는 요청에서만 와일드카드로 동작하고, 그때도 Authorization은 와일드카드에 포함되지 않으므로 명시적으로 적어야 합니다.

credentials 모드에서 와일드카드를 쓴 경우

Access to fetch at 'http://localhost:8000/api/me' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
Access to fetch at 'http://localhost:8000/api/me' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.

클라이언트가 credentials: 'include'로 요청했는데 서버가 Access-Control-Allow-Origin: *를 돌려주었거나, Access-Control-Allow-Credentials: true를 빠뜨린 경우입니다. 허용 목록에 있는 요청 Origin을 그대로 돌려주는 반사 패턴과 Access-Control-Allow-Credentials: true를 함께 써야 합니다. 자세한 조건은 아래 credentials 절에서 다룹니다.

헤더가 두 번 붙은 경우

Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values 'http://localhost:3000, http://localhost:3000', but only one is allowed.

애플리케이션 미들웨어와 역프록시(Nginx, API Gateway, CDN)가 각각 CORS 헤더를 붙이면 값이 중복됩니다. 최종 응답 헤더를 Network 탭에서 확인하고, 계층을 하나씩 빼 보면서 CORS 설정을 한 곳에만 남깁니다. OPTIONS 응답과 본 요청 응답이 같은 정책으로 나가는지도 함께 확인합니다.

응답은 받았는데 커스텀 헤더를 읽지 못하는 경우

에러 메시지는 없지만 response.headers.get('X-Total-Count')가 null을 돌려준다면, 그 헤더가 노출 목록에 없기 때문입니다. 교차 출처 응답에서 스크립트가 읽을 수 있는 헤더는 Content-Type, Content-Length, Cache-Control 같은 일부 안전한 헤더뿐이고, 나머지는 서버가 Access-Control-Expose-Headers에 나열해야 합니다.


credentials와 쿠키

fetch(url, { credentials: 'include' }) 또는 xhr.withCredentials = true로 요청하면 쿠키, Authorization 헤더, TLS 클라이언트 인증서 같은 자격 증명이 교차 출처 요청에 실릴 수 있습니다. fetch의 기본값은 credentials: 'same-origin'이라서, 다른 출처의 API에 쿠키를 보내려면 이 옵션을 반드시 명시해야 합니다. 옵션을 빠뜨려 쿠키가 안 실리는 것이 흔한 원인입니다.

서버 쪽에서는 세 가지가 맞아야 합니다. Access-Control-Allow-Credentials: true를 보내야 하고, Access-Control-Allow-Origin은 *가 아니라 요청 Origin과 일치하는 구체적인 값이어야 합니다. credentials 요청에서는 Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Expose-Headers의 *도 와일드카드가 아니라 문자 그대로의 이름으로 해석되므로 필요한 값을 모두 나열해야 합니다.

쿠키가 실리려면 CORS와 별개로 쿠키 속성도 맞아야 합니다. 다른 사이트(site)로 보내는 요청에 쿠키를 실으려면 SameSite=None이어야 하고, SameSite=None은 Secure 속성(HTTPS)을 함께 요구합니다. Domain·Path가 API 주소와 맞는지도 확인합니다. 그래서 “CORS 에러는 없어졌는데 로그인이 유지되지 않는다”면 CORS가 아니라 쿠키 속성을 볼 차례입니다. 최근 브라우저는 서드파티 쿠키 자체를 제한하는 방향이므로, 프런트엔드와 API를 같은 사이트(app.example.com과 api.example.com)에 두는 구성이 훨씬 안정적입니다.


Vary: Origin이 필요한 이유

허용 목록에 있을 때만 요청의 Origin을 Access-Control-Allow-Origin에 그대로 반사하는 패턴을 쓰면, 같은 URL이라도 요청한 출처에 따라 응답 헤더가 달라집니다. 이때 Vary: Origin이 없으면 CDN이나 프록시 같은 공유 캐시가 출처 A용 응답을 저장해 두었다가 출처 B의 사용자에게 그대로 내줄 수 있고, B에서는 CORS 에러가 납니다. 반사 패턴을 쓰는 응답에는 Vary: Origin을 함께 보내야 합니다. Express의 cors 패키지는 origin을 함수나 배열로 설정하면 이 헤더를 자동으로 추가합니다.


Express에서 CORS 설정

npm install cors
const express = require('express');
const cors = require('cors');

const app = express();

// 모든 출처 허용: 로컬 개발용으로만 씁니다.
app.use(cors());

app.get('/api/users', (req, res) => {
  res.json({ users: ['Alice', 'Bob'] });
});

app.listen(8000);

인자 없는 cors()는 Access-Control-Allow-Origin: *를 보내므로 credentials 요청에는 쓸 수 없습니다.

출처 허용 목록

운영 환경에서는 허용할 출처를 환경 변수로 받아 정확히 비교합니다.

const allowedOrigins = (process.env.CORS_ORIGINS || '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean);

app.use(cors({
  origin: (origin, callback) => {
    // Origin 헤더 없음: 같은 출처의 일부 요청, curl, 서버 간 호출
    if (!origin) return callback(null, true);
    // 허용되지 않으면 CORS 헤더만 빼고 요청은 정상 처리합니다.
    callback(null, allowedOrigins.includes(origin));
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
  maxAge: 7200,
}));

거부할 때 callback(new Error(...))를 넘기는 예제를 흔히 보는데, 그러면 cors 패키지가 에러를 Express 에러 핸들러로 넘겨 500 응답과 스택 트레이스가 나갑니다. 허용되지 않은 출처에서 온 평범한 요청이 서버 에러로 기록되고, 개발 모드에서는 내부 경로가 노출되기도 합니다. callback(null, false)로 CORS 헤더만 생략하면 브라우저가 알아서 응답을 차단하므로 이쪽이 의도에 맞습니다.

if (!origin) return callback(null, true);의 의미도 알고 써야 합니다. Origin 헤더가 없는 요청은 같은 출처의 일부 요청이거나 curl·서버 간 호출이므로 CORS와 무관하게 통과시키는 것이 맞습니다. 반면 문자열 "null"이라는 값의 Origin은 전혀 다른 경우입니다. 샌드박스된 iframe, file:// 페이지, 일부 리다이렉트에서 브라우저가 Origin: null을 보내는데, 공격자도 샌드박스 iframe으로 이 값을 쉽게 만들 수 있으므로 허용 목록에 "null"을 넣어서는 안 됩니다.

Origin을 무조건 반사하는 설정의 위험

credentials와 *를 함께 쓸 수 없다는 에러를 피하려고 cors({ origin: true, credentials: true })를 쓰는 경우가 있습니다. 이 설정은 어떤 사이트에서 온 요청이든 그 Origin을 그대로 허용하고 쿠키까지 실어 보내므로, 공격자 사이트의 스크립트가 로그인한 사용자의 권한으로 API를 호출하고 응답까지 읽을 수 있습니다. 사실상 동일 출처 정책을 끈 것과 같습니다. 허용 목록을 정규식으로 검사할 때도 /example\.com$/처럼 앞쪽을 고정하지 않으면 evil-example.com이 통과하므로, 스킴과 호스트 전체를 정확히 비교하는 편이 안전합니다.

특정 경로에만 CORS 적용

app.options('/api/users/:id', cors());  // 프리플라이트
app.put('/api/users/:id', cors(), (req, res) => {
  /* ... */
});

프리플라이트는 실제 요청과 같은 URL로 가므로, OPTIONS 라우트의 경로가 실제 라우트와 일치해야 합니다. /api/users에만 OPTIONS를 열고 /api/users/1에 PUT을 보내면 프리플라이트가 404로 실패합니다.


CORS를 피하는 구성

CORS 설정을 잘하는 것보다 교차 출처 요청 자체를 없애는 편이 단순한 경우가 많습니다.

  • 개발 환경에서는 Vite의 server.proxy나 Next.js의 rewrites로 /api 요청을 백엔드로 넘기면 브라우저 입장에서 같은 출처가 됩니다. 이렇게 하면 운영 환경과 구성이 달라지므로, 운영에서도 같은 출처로 서빙할 계획일 때 잘 맞습니다.
  • 운영 환경에서 프런트엔드와 API를 같은 도메인의 다른 경로(example.com과 example.com/api)로 역프록시하거나, 같은 출처의 BFF(Backend for Frontend) 라우트가 내부 API를 호출하게 하면 CORS가 필요 없습니다. 대신 BFF의 인증·쿠키 모델을 일관되게 설계해야 합니다.
  • Cloudflare Workers, AWS API Gateway, Kong 같은 게이트웨이에서 CORS를 일괄 처리할 수도 있습니다. 이때는 앱에서 CORS 헤더를 빼야 위의 “multiple values” 에러를 피할 수 있습니다.

누구나 읽어도 되는 공개 읽기 전용 데이터라면 Access-Control-Allow-Origin: *로 충분합니다. 다만 쿠키나 세션 기반 인증과는 함께 쓸 수 없습니다.


curl로 프리플라이트 확인

브라우저 없이도 프리플라이트 응답을 확인할 수 있습니다. curl은 CORS 검사를 하지 않으므로, 응답 헤더를 눈으로 비교해야 합니다.

curl -i -X OPTIONS "http://localhost:8000/api/users" \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

응답 상태 코드가 2xx인지, Access-Control-Allow-Origin이 요청한 Origin과 일치하는지, Access-Control-Allow-Methods와 Access-Control-Allow-Headers에 요청한 값이 들어 있는지 확인합니다. 본 요청도 -H "Origin: ..."을 붙여 보내 응답에 같은 헤더가 붙는지 확인하면 됩니다.


참고 자료

같이 보면 좋은 글