본문 바로가기
Etc2026년 9월 13일23분 읽기

웹 보안 헤더 총정리 — CSP·HSTS·CORS·X-Frame-Options 2026 실전

YS
김영삼
조회 13
웹 보안 헤더 총정리 — CSP·HSTS·CORS·X-Frame-Options 2026 실전

보안 헤더는 "브라우저에게 우리 사이트를 어떻게 다뤄라"라고 지시하는 응답 헤더다. 최소 세 개 — HSTS(HTTPS 강제), Content-Security-Policy(스크립트 출처 제한), X-Frame-Options/frame-ancestors(클릭재킹 차단) — 만 제대로 걸어도 XSS·중간자·클릭재킹의 대부분을 막는다.

문제는 CSP를 잘못 쓰면 사이트가 통째로 안 뜨고, CORS를 보안 기능으로 착각해서 헛발질을 하고, HSTS preload를 넣었다가 서브도메인 하나 때문에 몇 달을 고생한다는 것. 이 글은 각 헤더가 실제로 뭘 하는지, 그리고 프로덕션에 바로 붙일 수 있는 설정까지 정리했다.

나는 보안 헤더를 처음 진지하게 판 게 어떤 사이트가 iframe에 통째로 박혀 피싱에 쓰인 걸 본 뒤였다. 그전까지는 "HTTPS 걸었으니 됐지"였는데, HTTPS는 전송 구간만 지킨다. 브라우저가 콘텐츠를 어떻게 실행하고 어디에 끼워 넣을지는 별개의 문제고, 그걸 통제하는 게 보안 헤더다.

헤더 막는 공격 우선순위 잘못 걸었을 때 리스크
Strict-Transport-Security SSL 스트리핑, 중간자 필수 preload 넣으면 되돌리기 매우 어려움
Content-Security-Policy XSS, 데이터 유출, 인젝션 필수 엄격하면 사이트 스크립트 전멸
X-Frame-Options / frame-ancestors 클릭재킹 필수 정상 임베드가 깨짐
Permissions-Policy 서드파티 카메라·위치 남용 권장 특정 기능(결제 등) 차단

위 표에서 "필수" 세 개만 제대로 걸어도 실전 공격의 8할은 걷어낸다. 나머지는 방어를 겹겹이 쌓는 defense in depth 성격이다.

CORS는 보안 헤더가 아니다 — 가장 흔한 오해부터

먼저 이것부터 못 박고 가자. CORS는 당신의 서버를 보호하지 않는다. CORS(Cross-Origin Resource Sharing)는 오히려 same-origin policy라는 브라우저의 기본 방어를 완화하는 메커니즘이다. 다른 출처의 자바스크립트가 당신의 API 응답을 읽을 수 있게 허용해 주는 것이지, 요청 자체를 막는 방화벽이 아니다.

실무에서 이걸로 데인 사람이 정말 많다. "CORS를 *로 열어놨는데 해킹당했어요"라는 질문에는 오해가 두 개 겹쳐 있다. 첫째, CORS를 안 열어도 악성 서버는 당신의 API에 요청을 보낼 수 있다(curl, 서버 대 서버는 CORS와 무관). 둘째, Access-Control-Allow-Origin: *는 자격증명(쿠키)을 포함한 요청에서는 애초에 동작하지 않는다. 브라우저가 거부한다.

여기서 실무 함정 하나. Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true 는 동시에 쓸 수 없다. 쿠키를 실어야 하면 와일드카드 대신 요청의 Origin을 화이트리스트로 검증해 그 값을 그대로 반사(echo)하고, 반사할 땐 반드시 Vary: Origin을 붙여야 한다. 안 그러면 CDN이 첫 출처의 응답을 다른 출처에 캐싱해 준다.

// 안전한 동적 CORS (Express) — 화이트리스트만 반사
const ALLOWED = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin); // 와일드카드 금지
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin"); // 캐시 오염 방지 — 잊기 쉬움
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
    res.setHeader("Access-Control-Max-Age", "600"); // preflight 캐시 10분
    return res.sendStatus(204);
  }
  next();
});

정리하면 CORS는 "브라우저에서 실행되는 남의 코드가 내 응답 본문을 훔쳐 읽는 것"을 통제할 뿐이다. 서버 인가는 여전히 토큰·세션으로 별도로 해야 하고, CORS를 인증 대용으로 쓰는 순간 사고가 난다.

Content-Security-Policy 작성 — 어디서부터 시작하나

CSP는 "이 페이지에서 스크립트·스타일·이미지·연결을 어느 출처에서 불러올 수 있는지"를 브라우저에 선언하는 헤더다. XSS의 마지막 방어선인데, 공격자가 인젝션에 성공해도 <script>가 실행될 출처가 없으면 무력화된다. 시작점은 하나만 기억하면 된다: default-src 'self' 부터 깔고, 필요한 것만 열어라.

CSP를 처음 붙이면 십중팔구 사이트가 반쯤 죽는다. 인라인 스크립트, 애널리틱스, 폰트 CDN, 이미지 호스트... 전부 막히거든요. 그래서 실전 순서는 차단 모드로 바로 켜지 말고 리포트 전용(report-only)으로 며칠 돌려 뭐가 걸리는지 수집한 뒤 정책을 좁혀 가는 것이다.

디렉티브 통제 대상 현장 팁
default-src 지정 안 된 나머지 전부의 폴백 'self'로 시작
script-src JS 실행 출처 'unsafe-inline' 피하고 nonce/hash 사용
style-src CSS 출처 인라인 스타일 많으면 nonce가 현실적
connect-src fetch/XHR/WebSocket 대상 API·분석 엔드포인트 빠뜨리기 쉬움
frame-ancestors 누가 나를 iframe에 넣을 수 있나 X-Frame-Options의 상위 호환
base-uri <base> 태그 주입 방어 'self'로 잠가라, 놓치기 쉬움
# 1단계: 리포트 전용으로 관찰 (사이트 안 깨짐, 위반만 수집)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

# 2단계: 로그 보고 정책 좁힌 뒤 실제 차단으로 전환
Content-Security-Policy: default-src 'self';
  script-src 'self' 'nonce-{RANDOM}';
  style-src 'self' 'nonce-{RANDOM}';
  img-src 'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  object-src 'none'

한 가지 짚을 것. script-src'unsafe-inline'과 nonce를 같이 넣으면, nonce를 지원하는 최신 브라우저는 'unsafe-inline'을 무시한다(의도된 안전장치다). 반대로 'unsafe-inline' 'unsafe-eval'만 넣으면 사실상 CSP를 끈 것이나 다름없으니, 이 두 값이 보이면 정책을 다시 봐야 한다.

nonce와 hash — 인라인 스크립트를 안전하게 허용하기

nonce는 "요청마다 새로 만드는 일회용 난수 토큰"이다. 서버가 응답할 때 CSP 헤더와 <script> 태그에 같은 nonce를 박아두면, 브라우저는 그 nonce가 붙은 인라인 스크립트만 실행한다. 공격자가 XSS로 스크립트를 주입해도 현재 요청의 nonce를 모르니 실행이 안 된다. 핵심은 매 요청마다 예측 불가능한 값이라는 점 — 정적으로 하드코딩하면 의미가 없다.

// Node.js — 요청마다 nonce 생성해 헤더와 템플릿에 주입
import crypto from "node:crypto";

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.locals.nonce = nonce; // 템플릿에서 참조
  res.setHeader(
    "Content-Security-Policy",
    `default-src 'self'; ` +
    `script-src 'self' 'nonce-${nonce}'; ` +
    `style-src 'self' 'nonce-${nonce}'; ` +
    `object-src 'none'; base-uri 'self'; frame-ancestors 'none'`
  );
  next();
});

// 템플릿(예: EJS): 같은 nonce를 태그에 그대로
// <script nonce="<%= nonce %>">initApp();</script>

nonce가 안 맞는 상황도 있다. 응답을 캐싱하는 정적 페이지라면 요청마다 nonce를 바꿀 수 없다. 이럴 땐 hash를 쓴다. 스크립트 본문의 SHA-256을 계산해 'sha256-...' 형태로 정책에 넣으면 그 내용과 정확히 일치하는 인라인 스크립트만 허용된다. 내용이 고정된 인라인 코드에 적합하다. 참고로 Next.js·Nuxt 같은 프레임워크는 미들웨어에서 nonce를 헤더와 렌더 컨텍스트에 함께 흘려보내는 자체 방식이 있으니, 손으로 정규식 후처리하지 말고 프레임워크 CSP 가이드를 먼저 보는 게 빠르다.

CSP 리포트 — 뭐가 막혔는지 수집하기

CSP 위반이 발생하면 브라우저가 JSON 리포트를 지정한 엔드포인트로 POST해 준다. 이게 없으면 사용자 화면에서 뭐가 조용히 깨졌는지 알 길이 없다. 예전 방식은 CSP 헤더의 report-uri /csp-report, 최신 표준은 report-to(Reporting API) 디렉티브 + Reporting-Endpoints 헤더인데, 2026년 현재 report-to만 지원하고 report-uri를 뺀 브라우저가 늘고 있으니 둘 다 병기하는 게 안전하다. 수신 서버는 application/csp-report(구)와 application/reports+json(신) 두 콘텐츠 타입을 모두 파싱하면 된다.

한 가지 경고. 리포트 엔드포인트는 인증 없이 외부에서 POST가 쏟아진다. 브라우저 확장, 봇, 오래된 캐시가 온갖 노이즈를 보내니 도메인·디렉티브별로 집계해서 봐야 신호가 보인다. 나는 처음에 원시 로그를 다 알림으로 받다가 새벽에 폰이 터진 적이 있다.

HSTS와 preload — 강력하지만 되돌리기 어렵다

HSTS(Strict-Transport-Security)는 "앞으로 이 도메인은 무조건 HTTPS로만 접속해라"라고 브라우저에 기억시키는 헤더다. 한 번 받으면 max-age 기간 동안 브라우저가 http:// 접근을 아예 내부에서 https://로 바꿔 버린다. 첫 접속 시의 SSL 스트리핑 공격을 막는 핵심 장치다. 표준 권장 형태는 Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadmax-age는 최소 1년(31536000초)을 권한다.

세 부분을 나눠 보자. max-age는 이 규칙을 기억할 초 단위 기간. includeSubDomains는 모든 서브도메인에도 적용. preload는 브라우저에 내장된 목록(hstspreload.org)에 올려 첫 방문 전부터 HTTPS를 강제하겠다는 신청이다. 여기서 함정 — preload는 붙였다고 자동 등록이 아니라 hstspreload.org에서 별도 제출해야 하고, 한 번 등록되면 제거 요청 후 반영까지 수개월이 걸린다.

주의 includeSubDomains + preload를 켜기 전에 모든 서브도메인이 HTTPS를 지원하는지 확인하라. 사내 legacy.example.com이 HTTP 전용이면 그 서브도메인은 전 세계 브라우저에서 접속 불가가 된다. 롤백? preload 목록 갱신은 느리고, 이미 캐시된 사용자 브라우저는 max-age가 만료돼야 풀린다. 실무 순서는 짧은 max-age=300으로 시작 → 문제없음 확인 → 1년으로 늘리기 → 그다음에 preload다.

클릭재킹 방어 — X-Frame-Options vs frame-ancestors

클릭재킹은 공격자가 당신의 페이지를 투명 iframe에 넣고 그 위에 가짜 버튼을 덮어, 사용자가 모른 채 진짜 페이지의 버튼(예: 송금·권한 승인)을 누르게 만드는 공격이다. 방어는 "누가 나를 iframe에 넣을 수 있나"를 제한하는 것. 여기엔 헤더가 두 개 있는데, 하나는 구식이다.

X-Frame-OptionsDENY(아무도 못 넣음) 또는 SAMEORIGIN(같은 출처만) 두 값이 사실상 전부다. 반면 CSP의 frame-ancestors는 특정 출처 목록을 지정할 수 있고 최신 표준이다. 둘이 충돌하면 frame-ancestors가 이긴다. 개인적으로는 CSP를 쓰는 이상 frame-ancestors만 쓰고 X-Frame-Options는 아주 오래된 브라우저 호환용으로만 병기한다.

# 아무도 임베드 못 하게 (가장 안전한 기본값)
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

# 우리 사이트 + 특정 파트너만 허용하려면 frame-ancestors로만 가능
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com

X-Frame-Options는 "우리 도메인 + 파트너 한 곳"처럼 세밀한 지정이 불가능하다. 그게 필요한 순간 무조건 frame-ancestors로 가야 한다. 참고로 ALLOW-FROM 값은 대부분의 최신 브라우저가 더 이상 지원하지 않으니 쓰지 마라.

Permissions-Policy — 브라우저 기능 잠그기

Permissions-Policy(옛 이름 Feature-Policy)는 카메라·마이크·위치·결제 같은 강력한 브라우저 API를 "이 페이지와 어떤 iframe이 쓸 수 있는지" 제어한다. 안 쓰는 기능은 꺼두는 게 원칙이다. 서드파티 스크립트나 임베드된 광고가 몰래 위치·카메라를 요청하는 걸 원천 차단한다. 헤더는 이런 식이다 — Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(self), interest-cohort=().

문법이 헷갈리기 쉽다. geolocation=()는 "아무도 못 씀"(빈 허용 목록), camera=(self)는 "내 출처만", camera=*는 "모두 허용"이다. 빈 괄호가 곧 차단이라는 게 직관과 반대라 처음엔 나도 헷갈렸다. interest-cohort=()는 예전 FLoC 추적 옵트아웃 잔재인데 넣어둬서 나쁠 건 없다. 그리고 결제 위젯이나 화상통화 SDK를 iframe으로 붙였는데 기능이 갑자기 안 되면, 상위 문서 헤더에서 그 출처를 허용하고 <iframe allow="camera; payment"> 속성도 함께 줘야 한다 — 헤더와 iframe 속성 둘 다 필요하다.

실전 설정 — nginx와 Express 전체 예시

이론은 됐고 바로 붙일 수 있는 설정을 보자. 아래는 nonce가 필요 없는 일반 사이트용 예시고, nonce가 필요하면 CSP만 앱 레벨에서 동적으로 세팅하고 나머지는 여기 두면 된다.

# /etc/nginx/conf.d/security-headers.conf
# server 블록 안에서 include 하거나 http 블록에 두고 재사용

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
주의 nginx add_header의 함정: 하위 블록(location 등)에서 add_header를 하나라도 다시 쓰면 상위 블록의 add_header가 전부 사라진다. 상속이 아니라 덮어쓰기다. 이걸 몰라서 특정 경로에서만 보안 헤더가 통째로 빠지는 사고가 흔하다. always 플래그도 잊지 마라 — 없으면 4xx/5xx 응답엔 헤더가 안 붙는다.

Node/Express 진영이라면 손으로 붙이지 말고 helmet 미들웨어를 쓰는 게 낫다. app.use(helmet())만 해도 X-Content-Type-Options·X-Frame-Options·HSTS 같은 안전한 기본값이 켜지고, CSP만 helmet.contentSecurityPolicy({ directives: {...} })로 위 nginx와 동일하게 지정하면 된다.

설정을 붙였으면 검증은 필수다. curl -sI https://example.com로 응답 헤더를 눈으로 확인하고, securityheaders.com 스캐너 등급과 브라우저 콘솔의 CSP 위반 경고를 함께 본다. 배포 후 실제 페이지를 클릭하며 콘솔에 빨간 CSP 에러가 없는지 보는 게 가장 확실하다.

전부 한 번에 완벽하게 하려다 지치기 쉽다. 나는 이 순서로 붙인다. (1) X-Content-Type-Options: nosniff — 부작용 제로, 지금 당장. (2) HSTS — max-age 짧게 시작해 검증 후 1년, preload는 맨 마지막. (3) frame-ancestors 'none' — 임베드 필요 없으면 즉시. (4) Referrer-Policy·Permissions-Policy — 저비용 고효율. (5) CSP — report-only로 관찰 → 좁히기 → 차단. 손은 많이 가지만 가장 강력하다. CORS가 이 목록에 없다는 걸 다시 짚는다. 보안 강화가 아니라 필요할 때만 켜는 기능이다.

자주 묻는 질문

CSP를 켜니 사이트 스크립트가 전부 안 돌아갑니다. 어떻게 하죠?

먼저 Content-Security-Policy 대신 Content-Security-Policy-Report-Only로 바꿔 사이트를 살린 뒤, 브라우저 콘솔과 리포트 로그에서 어떤 출처·인라인 스크립트가 막히는지 수집하세요. 그다음 인라인 스크립트는 nonce나 hash로 허용하고, 외부 CDN은 script-src에 도메인을 명시적으로 추가합니다. 'unsafe-inline'으로 뭉개는 건 최후의 임시방편입니다.

CORS만 잘 설정하면 API가 안전한가요?

아니요. CORS는 브라우저에서 실행되는 남의 스크립트가 응답을 읽는 걸 통제할 뿐, 요청 자체를 막지 못합니다. curl이나 서버 대 서버 호출은 CORS와 무관하게 통과합니다. 인증·인가는 토큰이나 세션으로 별도로 반드시 구현해야 합니다.

HSTS preload를 넣었다가 후회하면 뺄 수 있나요?

뺄 수는 있지만 매우 느립니다. hstspreload.org에서 제거를 요청해도 브라우저 내장 목록 갱신과 배포에 수개월이 걸리고, 그 사이 사용자 브라우저에 캐시된 규칙은 max-age가 만료돼야 풀립니다. 그래서 preload는 모든 서브도메인의 HTTPS를 100% 확인한 뒤에만 신청하는 게 정석입니다.

X-Frame-Options와 CSP frame-ancestors를 둘 다 넣어야 하나요?

최신 브라우저만 대상이면 frame-ancestors 하나로 충분하며, 둘이 충돌하면 frame-ancestors가 우선합니다. 다만 아주 오래된 브라우저 호환을 위해 X-Frame-Options: DENY를 함께 병기하는 걸 권장합니다. 특정 파트너만 허용하는 세밀한 제어는 frame-ancestors로만 가능합니다.

nonce와 hash 중 뭘 써야 하나요?

응답이 요청마다 동적으로 렌더링되면 nonce가 편하고, 응답을 캐싱하는 정적 페이지라면 내용 기반 hash가 맞습니다. nonce는 매 요청 새 난수여야 하므로 캐시된 응답에 재사용하면 보안 의미가 사라집니다. 인라인 코드가 고정돼 있으면 SHA-256 hash를 쓰세요.

nginx에서 특정 경로만 보안 헤더가 빠집니다. 왜죠?

해당 location 블록 안에서 add_header를 하나라도 다시 썼기 때문입니다. nginx의 add_header는 상속이 아니라, 하위 블록에 하나라도 있으면 상위의 모든 add_header를 덮어씁니다. 그 location에 필요한 헤더를 전부 다시 나열하거나, 공통 헤더 파일을 각 블록에서 include하세요.

보안 헤더가 제대로 적용됐는지 어떻게 확인하나요?

curl -sI https://도메인으로 응답 헤더를 직접 확인하는 게 가장 빠릅니다. 이어서 securityheaders.com 같은 스캐너로 등급을 보고, 브라우저 개발자도구 콘솔에서 CSP 위반 경고가 없는지 실제 페이지를 클릭하며 점검하세요. 배포 파이프라인에 헤더 검증 스텝을 넣어두면 회귀를 막을 수 있습니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록