[Network] CORS: Same-Origin Policy, preflight, credentials
정의
CORS (Cross-Origin Resource Sharing) 는 Same-Origin Policy (SOP) 의 완화 메커니즘. 브라우저가 origin 이 다른 리소스에 조건부로 접근 할 수 있게 한다.
IMPORTANT
CORS 는 브라우저의 보안 모델. 서버 -> 서버 호출에는 영향 없음. curl, Postman 도 CORS 적용 안 함. 오로지 브라우저가 JS 코드의 cross-origin fetch 를 막거나 허용한다.
Origin = scheme + host + port
| URL | Origin |
|---|---|
https://app.example.com/page | https://app.example.com |
https://app.example.com:443 | https://app.example.com (default port) |
http://app.example.com | http://app.example.com (scheme 다름) |
https://api.example.com | https://api.example.com (host 다름) |
Simple Request vs Preflight
flowchart TD
Q{"요청이 Simple?"}
Q -->|예| S["즉시 보냄"]
Q -->|아니오| P["OPTIONS preflight 먼저"]
S --> CHECK1["응답의 ACAO 검증"]
P --> CHECK2["preflight 응답 검증"]
CHECK2 -->|허용| Send["실제 요청 전송"]
CHECK2 -->|거절| Block["브라우저 차단"]
Simple Request 조건 (모두 만족)
- Method: GET, HEAD, POST
- 헤더: 안전한 헤더 +
Content-Type: application/x-www-form-urlencoded | multipart/form-data | text/plain - 이벤트 listener 없음 (XHR upload)
ReadableStreambody 없음
위 조건 하나라도 어기면 preflight. JSON Content-Type: application/json 도 preflight 유발.
Preflight 흐름
sequenceDiagram
autonumber
participant B as Browser
participant S as Server
B->>S: OPTIONS /api/users<br/>Origin: https://app.example.com<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: content-type, authorization
S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: https://app.example.com<br/>Access-Control-Allow-Methods: GET, POST, DELETE<br/>Access-Control-Allow-Headers: content-type, authorization<br/>Access-Control-Max-Age: 86400
Note over B: 캐시 (Max-Age)
B->>S: POST /api/users<br/>Origin: https://app.example.com<br/>Content-Type: application/json<br/>{...}
S-->>B: 201 Created<br/>Access-Control-Allow-Origin: https://app.example.com
Response 헤더 가이드
| 헤더 | 의미 |
|---|---|
Access-Control-Allow-Origin | 허용된 origin (하나만 또는 *) |
Access-Control-Allow-Methods | preflight 응답에서 허용 method |
Access-Control-Allow-Headers | 허용 헤더 |
Access-Control-Expose-Headers | JS 가 읽을 수 있는 응답 헤더 |
Access-Control-Max-Age | preflight 캐시 (초) |
Access-Control-Allow-Credentials | true 면 cookie/Authorization 허용 |
Credentials (cookie / Authorization)
fetch('https://api.example.com/me', {
credentials: 'include', // cookie 보내기
});
이 경우 서버는 반드시:
Access-Control-Allow-Origin: https://app.example.com # * 불가능
Access-Control-Allow-Credentials: true
CAUTION
Allow-Origin: * + Allow-Credentials: true 는 RFC 위반. 브라우저가 거절. credentials 가 필요하면 명시적 origin.
서버 구현 가이드
Express (Node.js)
import cors from 'cors';
app.use(cors({
origin: ['https://app.example.com', 'https://staging.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400,
}));
Spring Boot
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(
"https://app.example.com",
"https://staging.example.com"
)
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("X-Total-Count")
.allowCredentials(true)
.maxAge(86400);
}
};
}
}
nginx
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
proxy_pass http://backend;
}
WARNING
nginx 에서 $http_origin 를 그대로 반영하면 모든 origin 허용 과 같음. 허용된 origin 목록을 map 디렉티브로 먼저 검증해야 한다.
동적 Origin 허용 패턴
Allow-Origin 에 하나의 값만 담을 수 있다. 여러 origin 을 허용할 때:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
'http://localhost:3000',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin'); // CDN 캐시 분리
}
next();
});
Vary: Origin 이 없으면 CDN 이 다른 origin 의 응답 을 캐시해서 제공. 반드시 추가.
SOP 가 막는 것들
fetch('https://other.com')의 응답 읽기- 다른 origin iframe 의 DOM 접근
- 다른 origin 의 cookie 읽기
SOP 가 막지 않는 것 (CSRF 의 토대):
<form action="https://other.com">의 POST<img src="https://other.com/api">(GET)<script src="https://other.com/api">(실행됨)
CORS vs CSRF
| CORS | CSRF | |
|---|---|---|
| 본질 | cross-origin fetch 허용 | cross-origin 의 위조 요청 방어 |
| 방향 | 브라우저 -> 서버 허용 | 브라우저 -> 서버 거절 |
| 토대 | Same-Origin Policy 의 완화 | Same-Origin Policy 의 우회 방어 |
| 도구 | Response 헤더 (ACAO) | CSRF token, SameSite cookie |
자세한 건 CSRF 참고.
개발 환경 팁
로컬 개발 시 localhost:3000 (프론트) -> localhost:8080 (백엔드) 도 cross-origin.
- 프록시:
vite.config.ts의server.proxy로/api/*를 백엔드로 라우팅 (브라우저 관점에서 same-origin) - 백엔드 allowOrigin:
http://localhost:3000을 명시적으로 허용 - HTTPS 불일치: 로컬에서
http://, 프로덕션에서https://를 혼용하면 다른 origin
흔한 함정
WARNING
Allow-Origin: *를 모든 경우에 박음: credentials 모드에서 동작 안 함.- preflight 응답에 200 보냄: 보통 204 No Content 가 정석. 200 도 동작은 하지만 관습 아님.
Vary: Origin누락: CDN 이 다른 origin 응답 을 캐시. 모든 사용자에게 같은 origin 의 응답 이 감.- CORS 가 서버 보안이라고 오해: 브라우저 보안. curl 은 CORS 무시. 서버 보안은 auth, IP allowlist 등 별도.
- 로컬 환경만 허용:
http://localhost:3000만 허용해두고 프로덕션 cross-origin 미테스트.
CORS 디버깅 체크리스트
브라우저 콘솔에서 CORS 오류가 났을 때:
- Network 탭 확인: OPTIONS preflight 가 발생했는지, 응답 코드 확인
- 응답 헤더 확인:
Access-Control-Allow-Origin이 실제로 존재하는지 - origin 비교: 요청
Origin헤더와 응답ACAO값이 정확히 일치하는지 (trailing slash 주의) - credentials 모드:
credentials: 'include'인데ACAO: *이면 실패 - CDN 캐시:
Vary: Origin없이 캐싱된 preflight 응답이 문제일 수 있음
// 빠른 CORS 테스트
fetch('https://api.example.com/health', {
method: 'OPTIONS',
headers: {
'Origin': 'https://app.example.com',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
},
}).then(res => {
console.log('Status:', res.status);
console.log('ACAO:', res.headers.get('access-control-allow-origin'));
console.log('Credentials:', res.headers.get('access-control-allow-credentials'));
});
Private Network Access (PNA)
Chrome 94+ 에서 추가된 보안 정책. Public internet -> Private network (192.168.x.x, 10.x.x.x) 로의 요청도 preflight 를 요구.
# PNA preflight 에 추가 헤더
Access-Control-Request-Private-Network: true
# 서버 응답 필요
Access-Control-Allow-Private-Network: true
로컬 개발 서버 (localhost) 에 외부 스크립트가 접근할 때 발생. 서버에 위 헤더 추가 필요.
관련 위키
- csrf
- Session Cookie
- JWT
- HTTP/1.1
- TLS
- http-2 - HTTP/2 에서도 CORS 동일 적용
- network-http-caching -
Vary: Origin과 캐시 관계
이 글의 용어 (7개)
- [Auth] CSRF: 위조 요청 공격과 방어auth-security
- 정의 CSRF (Cross-Site Request Forgery) 는 사용자가 로그인된 상태 에서 공격자가 만든 페이지 가 피해자 브라우저로 위조 요청 을 보내는 공격. 쿠키 기…
- [Auth] JWT: 구조, 서명, 만료, refresh tokenauth-security
- 정의 JWT (JSON Web Token) (RFC 7519) 은 base64url 인코딩된 JSON + 서명 형태의 self-contained token. 서버가 세션 저장 없…
- [Auth] Session Cookie: HttpOnly, SameSite, Secure flagauth-security
- 정의 Session Cookie 는 서버가 발급한 임의 ID 를 쿠키에 보관. 매 요청 자동 첨부. 서버 측 세션 store (Redis, DB) 와 짝. [!IMPORTANT]…
- [Network] HTTP Caching: Cache-Control, ETag, Last-Modifiednetwork
- 정의 HTTP Caching 은 HTTP 응답을 클라이언트 / 중간 proxy / CDN 이 보관해 같은 요청을 다시 처리하지 않게 하는 메커니즘. 성능 + 비용 절감의 핵심. …
- [Network] HTTP/1.1: keep-alive, pipelining, chunked transfernetwork
- 정의 HTTP/1.1 (1997, RFC 9112 으로 재정리) 는 텍스트 기반 stateless request/response 프로토콜. 2026 시점에도 대부분의 트래픽이 H…
- [Network] HTTP/2: multiplexing, HPACK, server push의 종말network
- 정의 HTTP/2 (2015, RFC 9113) 는 HTTP/1.1 의 문법은 유지하되 전송을 바이너리 + 멀티플렉싱 으로 바꾼다. Head-of-Line Blocking (HT…
- TLS / SSLnetwork
- 정의 TLS (Transport Layer Security) 는 (또는 위 ) 위에서 동작하는 암호화·인증 프로토콜이다. SSL 은 TLS 의 전신 (Netscape 1995, …
이 개념을 다룬 위키 페이지 (6)
- wiki[Auth] CSRF: 위조 요청 공격과 방어
- wiki[Auth] JWT: 구조, 서명, 만료, refresh token
- wiki[Auth] Session Cookie: HttpOnly, SameSite, Secure flag
- wiki[Django 6.0] Content Security Policy: XSS 방어의 최종형
- wiki[K8s] Ingress: L7 라우팅, TLS termination, Gateway API
- wiki[K8s] Service: ClusterIP / NodePort / LoadBalancer / ExternalName
💬 댓글