본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

CORS 제대로 이해하기 — 서버가 선언하고 브라우저가 집행해

~12 min · auth-security, cors, preflight, browser

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"CORS는 인증이나 서버 쪽 권한 검사를 대신하지 않아. 서버가 선언한 교차 출처 응답 공유 정책을 브라우저가 집행하는 장치야. 이 경계를 잡으면 요청이 서버에 도착했는지, 브라우저가 응답을 스크립트에 공개했는지부터 분리해서 볼 수 있어."

CORS를 푸는 두 가지 관점

CORS 문제를 볼 때는 다음 두 사실을 함께 기억해:

  1. CORS는 브라우저가 집행해. curl, Python httpx, 서버 간 HTTP 호출은 브라우저의 동일 출처 정책을 따르지 않아. 따라서 CORS 응답 헤더가 없어도 요청하고 응답을 읽을 수 있어.
  2. 정책은 서버가 선언해. 서버는 허용할 출처와 메서드, 요청 헤더를 Access-Control-Allow-* 응답 헤더로 알리고, 브라우저는 그 선언을 바탕으로 응답을 JavaScript에 노출할지 판단해.

그래서 CORS 오류가 보이면 먼저 서버 도달 여부와 응답 헤더를 나눠 확인해야 해. 단순 요청은 서버에서 처리된 뒤에도 응답 노출만 차단될 수 있고, 프리플라이트가 실패한 요청은 실제 요청 자체가 전송되지 않을 수 있어.

출처는 무엇으로 결정될까

출처(scheme, host, port)의 조합이야. 기본 포트는 정규화되므로 https://example.comhttps://example.com:443은 같은 출처지만, http://example.com은 스킴이 달라 다른 출처야. 호스트나 유효 포트가 달라도 교차 출처가 돼.

동일 출처 정책(SOP)은 한 출처의 JavaScript가 다른 출처의 응답을 마음대로 읽지 못하게 하는 브라우저의 기본 방어야. CORS는 리소스 서버가 특정 교차 출처에 응답 읽기를 허용하도록 이 제한을 안전하게 완화해. 다만 요청 전송 자체를 모두 막는 CSRF 방어는 아니므로 쿠키 인증 API에는 별도의 CSRF 대책이 필요해.

단순 요청과 프리플라이트 요청

브라우저는 교차 출처 요청을 크게 두 가지로 처리해.

단순 요청은 다음 조건을 모두 만족해 프리플라이트가 필요하지 않은 요청이야:

  • 메서드가 GET, HEAD, POST 가운데 하나야.
  • 요청 헤더가 Accept, Accept-Language, Content-Language와 일부 Content-Type처럼 CORS 안전 목록에 들어가.
  • Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 가운데 허용된 형태야.

브라우저는 단순 요청을 먼저 보내고, 응답의 Access-Control-Allow-Origin: matching-origin을 확인해. 값이 맞으면 응답을 JavaScript에 노출하고, 없거나 맞지 않으면 스크립트가 응답을 읽지 못하게 해. 서버에서 이미 일어난 부작용까지 되돌려 주지는 않아.

프리플라이트가 필요한 요청에는 Content-Type: application/json인 POST나 Authorization 같은 안전 목록 밖의 헤더를 쓰는 요청이 포함돼:

  1. 브라우저가 실제 요청 전에 같은 URL로 Origin, Access-Control-Request-Method, Access-Control-Request-Headers를 담은 OPTIONS 요청을 보내.
  2. 서버는 허용 범위를 Access-Control-Allow-Origin/Methods/Headers로 응답해.
  3. 브라우저는 요청하려는 출처·메서드·헤더가 허용 범위에 들어가는지 비교해. 맞지 않으면 실제 요청을 보내지 않아.
  4. 검사를 통과하면 실제 요청을 보내고, 실제 응답의 Access-Control-Allow-Origin도 다시 확인한 뒤 스크립트에 노출해.

프리플라이트는 왕복을 하나 추가하지만, 서버는 Access-Control-Max-Age로 결과의 캐시 시간을 제안할 수 있어. 실제 캐시 상한은 브라우저마다 다를 수 있으므로 무한정 유지된다고 가정하면 안 돼.

핵심 응답 헤더 다섯 가지

  • Access-Control-Allow-Origin — 응답을 읽도록 허용할 구체적인 출처나 * 와일드카드야. 요청의 Origin을 그대로 반사하지 말고 먼저 허용 목록과 정확히 대조해. 출처별로 값을 바꿔 반환하고 공유 캐시를 쓴다면 Vary: Origin도 필요해.
  • Access-Control-Allow-Credentials — 쿠키처럼 브라우저가 관리하는 자격 증명을 포함한 요청의 응답을 노출하려면 true로 설정해. 클라이언트도 credentials 모드를 알맞게 설정해야 해. 명시적 Authorization 헤더는 별도로 프리플라이트의 허용 헤더 검사도 통과해야 해. 자격 증명 모드에서는 Access-Control-Allow-Origin: *를 사용할 수 없어.
  • Access-Control-Allow-Methods — 프리플라이트 응답에서 실제 요청에 허용할 메서드를 알려줘.
  • Access-Control-Allow-Headers — 프리플라이트 응답에서 실제 요청에 허용할 헤더를 알려줘.
  • Access-Control-Max-Age — 브라우저가 프리플라이트 결과를 캐시할 시간을 초 단위로 제안해.
CORS는 브라우저 보안 장치지만 서버 인증은 아니야. 악성 웹사이트가 사용자의 브라우저와 기존 자격 증명을 발판으로 다른 출처의 민감한 응답을 읽는 일을 제한해. 그러나 비브라우저 클라이언트에는 적용되지 않고, 허용된 출처의 코드가 안전하다는 사실도 보장하지 않아. 서버는 CORS 허용 여부와 별개로 모든 요청의 인증과 권한, CSRF 위험을 검증해야 해.

와일드카드와 자격 증명의 함정

자주 만나는 CORS 오류는 두 가지야:

1. Access-Control-Allow-Origin: * + 쿠키. 자격 증명 모드의 요청에 와일드카드를 반환하면 브라우저가 응답을 스크립트에 노출하지 않아. 쿠키가 전송되지 않았다고 가정해서도 안 돼. 허용 목록에서 Origin을 검증한 뒤 구체적인 출처를 반환하고 Vary: Origin을 설정해.

2. 프리플라이트는 성공 상태인데 CORS 헤더가 없어. OPTIONS 응답이 200이나 204여도 Access-Control-Allow-Origin과 필요한 허용 헤더가 빠지면 브라우저는 실패로 처리해. 실제 응답뿐 아니라 프리플라이트 응답에도 올바른 CORS 헤더가 있어야 해.

cwkPippa의 CORS 구성

cwkPippa는 backend/main.py_allowed_originshttp://localhost:5173, http://127.0.0.1:5173, 허용한 Tailscale 출처처럼 구체적인 값을 등록해. 쿠키를 포함한 자격 증명 요청을 지원해야 하므로 무조건적인 와일드카드를 쓰지 않아. FastAPI의 CORSMiddleware가 프리플라이트와 실제 응답 헤더를 처리하므로 각 엔드포인트에 OPTIONS 처리기를 반복해서 만들 필요는 없어. 새 접속 출처를 추가할 때는 정확한 스킴·호스트·포트를 허용 목록에 반영하고 애플리케이션을 다시 시작해야 해. 연결 오류를 조사할 때도 실행 중인 설정과 요청의 Origin을 먼저 비교하면 돼.

Code

전체 CORS 흐름 — 프리플라이트 OPTIONS와 실제 요청·http
# Preflight request (브라우저가 실제 POST 전에 이거 보냄)
OPTIONS /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

# Preflight response (server 가 허용된 거 선언)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type, x-request-id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

# 이제 브라우저가 실제 POST 보냄 (response 도 CORS header 필요)
POST /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer abc.def.ghi
Content-Type: application/json

{"message":"hi pippa"}

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"reply":"hi 아빠"}
FastAPI CORSMiddleware — 명시적 허용 목록과 자격 증명·python
# FastAPI — CORSMiddleware 가 preflight + 실제 request header 처리
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# 빡빡 allowlist — credentials 쓸 때 와일드카드 안 됨
_allowed_origins = [
    'http://localhost:5173',
    'http://127.0.0.1:5173',
    'http://100.x.x.x:5173',  # Tailscale IP
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=_allowed_origins,
    allow_credentials=True,                # cookie + Authorization 허용
    allow_methods=['*'],                   # GET, POST, PUT, DELETE, PATCH, OPTIONS
    allow_headers=['*'],                   # 어느 request header 든
    expose_headers=['X-Request-ID'],       # JS 가 읽을 수 있는 response header
    max_age=86400,                         # 24h 동안 preflight 캐시
)

@app.get('/api/chat')
async def chat():
    return {'ok': True}
# OPTIONS /api/chat 가 CORSMiddleware 가 자동 처리
클라이언트 — 교차 출처 쿠키를 포함하는 fetch 요청·javascript
// Client 쪽 — 다른 origin 에서 fetch
// CORS 가 브라우저 일; client 코드에서 'enable' 안 함.
const resp = await fetch('https://api.example.com/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer abc.def.ghi',
  },
  credentials: 'include',  // cookie 보냄 — server 가 credentials 허용 필요
  body: JSON.stringify({ message: 'hi pippa' }),
});

// Server 의 preflight response 가 맞는 Origin/Methods/Headers 선언하면
// 브라우저가 실제 request 허용하고 response 노출.
// 아니면: TypeError: NetworkError when attempting to fetch resource.
// 브라우저 콘솔이 실패한 specific CORS 체크 보여줘.

External links

Exercise

8000번 포트에 작은 FastAPI 서버를 띄우고, 5500번 포트에서는 fetch('http://localhost:8000/data')를 실행하는 정적 HTML/JavaScript를 python -m http.server 5500으로 제공해. 브라우저에서 CORS 오류가 나는지 확인한 뒤 FastAPI에 allow_origins=['http://localhost:5500']를 지정한 CORSMiddleware를 추가하고 다시 호출해봐. 보너스로 fetch에 credentials: 'include'를 추가하고 CORSMiddleware에 allow_credentials=True를 설정해 자격 증명 응답이 동작하도록 만들어. 마지막에는 allow_origins=['*']로 바꿔 자격 증명 모드와 와일드카드가 충돌하는지 관찰해.
Hint
브라우저에서 응답 읽기가 CORS로 차단된 요청도 curl이나 httpx에서는 정상적으로 읽을 수 있어. 단순 요청이라면 서버에는 이미 도착했을 수 있다는 점도 함께 확인해. 브라우저 네트워크 탭에서 OPTIONS와 실제 요청을 구분하고, Content-Type: application/json이나 Authorization 헤더를 추가해 프리플라이트가 생기는지도 살펴봐. 콘솔 오류와 응답의 CORS 헤더를 나란히 보면 원인을 찾기 쉬워.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.