HTTP 보안 헤더 설정 — Nginx·Express 환경별 실전 가이드

목차

어제 운영 서버 점검하다가 securityheaders.com 스캔 결과에서 D를 받았다. HTTP 보안 헤더 설정을 5년 전에 한 번 박아두고 잊고 살았는데, 그 사이에 X-XSS-Protection은 deprecated 됐고 Permissions-Policy가 Feature-Policy를 대체했다. 오늘 이걸 다시 정리하면서 알게 된 걸 메모로 남긴다.

새 기술이라 손대고 싶지는 않았다. 잘 돌아가는 헤더를 굳이 건드리는 건 내 스타일이 아니라서. 그런데 스캔 등급이 D면 외부에 보여주기 부끄러운 수준이라 어쩔 수 없이 작업했다. 결과적으로 A까지 끌어올렸고, Nginx와 Express 양쪽에서 실제로 박아둔 값을 그대로 옮긴다.

오늘 다시 손본 헤더 4개

curl -I https://example.com 한 번 찍어보면 응답 헤더가 다 나온다. 거기서 빠진 항목이 보안 등급을 깎는 주범이다. 이번에 다시 손본 건 네 개였다.

실제로, HSTS (Strict-Transport-Security) — HTTPS 강제. 이건 5년 전부터 박혀 있었지만 max-age가 너무 짧았다. 600초로 깔아둔 걸 발견하고 헛웃음이 나왔다. 권장값은 최소 1년(31536000초)이다. preload 리스트(hstspreload.org)에 등록하려면 includeSubDomainspreload 디렉티브가 필수다.

즉, X-Frame-Options — clickjacking 방어. 페이지를 iframe으로 못 박게 막는다. 요즘은 CSP의 frame-ancestors가 더 강력하지만 구형 브라우저 호환 때문에 둘 다 박는 게 안전하다. 값은 DENY 아니면 SAMEORIGIN. 외부 임베드를 허용하지 않을 거면 DENY가 깔끔하다.

이처럼, X-Content-Type-Options — MIME 스니핑 차단. nosniff 한 값만 있다. 브라우저가 응답 본문 보고 멋대로 타입을 추측하는 걸 막는다. JS 파일을 이미지로 위장해서 올리는 공격 벡터를 차단한다고 보면 된다.

즉, Permissions-Policy — Feature-Policy의 후속. 카메라, 마이크, 위치, 자이로 같은 브라우저 API 접근을 도메인 단위로 제어한다. 헤더 문법이 Feature-Policy와 살짝 달라서 옮길 때 한 번 봐야 한다. 카메라를 안 쓰는 서비스면 camera=()로 막아두는 게 좋다.

헤더별 권장값 한 줄 정리

매번 MDN 뒤적이기 귀찮아서 자주 쓰는 값만 표로 정리해뒀다.

헤더 권장값 비고
Strict-Transport-Security max-age=31536000; includeSubDomains; preload preload는 등록 신청 후에
X-Frame-Options DENY 외부 임베드 필요 시 SAMEORIGIN
X-Content-Type-Options nosniff 값은 이거 하나뿐
Referrer-Policy strict-origin-when-cross-origin 보수적이면서 분석 데이터는 유지
Permissions-Policy camera=(), microphone=(), geolocation=() 쓰는 기능만 풀어주기
Content-Security-Policy 서비스마다 다름 별도 글 주제

CSP는 한 줄로 못 끝낸다. 인라인 스크립트, nonce, hash, report-uri까지 엮이면 따로 글을 써야 할 분량이라 이번엔 뺐다.

Nginx 설정

한편, 기존 서버 블록에 add_header 다섯 줄을 추가했다. always 플래그는 잊지 말아야 한다. 이걸 빠뜨리면 4xx, 5xx 응답에서는 헤더가 안 붙는다. 이게 securityheaders.com이 에러 페이지로 스캔할 때 등급이 깎이는 원인이었다.

# /etc/nginx/sites-available/example.com
server {
    listen 443 ssl http2;
    server_name example.com;

    # HTTPS 강제 (1년, 서브도메인 포함, preload 후보)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    # iframe 차단
    add_header X-Frame-Options "DENY" always;

    # MIME 스니핑 차단
    add_header X-Content-Type-Options "nosniff" always;

    # Referer 최소화
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # 안 쓰는 브라우저 API 전부 차단
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;

    # ... 나머지 설정
}

interest-cohort=()는 Google FLoC 차단용으로 박아둔 거다. 지금은 FLoC 자체가 폐기됐지만 추적성 기능을 명시적으로 끄는 의미로 남겨둔다.

설정 반영 전에 nginx -t로 문법 체크하고, systemctl reload nginx로 무중단 반영한다. reload는 워커를 graceful하게 교체하기 때문에 운영 중에 돌려도 연결이 끊기지 않는다. 5년 동안 reload 때문에 사고 난 적은 없다.

add_header가 상속 안 되는 함정

여기서 한 번 시간을 날렸다. add_header를 server 블록에 박아도 location 블록 안에 또 다른 add_header가 있으면 server 블록 헤더가 전부 사라진다. 상속이 아니라 덮어쓰기다.

server {
    add_header X-Frame-Options "DENY" always;

    location /api/ {
        add_header X-API-Version "v2" always;
        # 여기서 X-Frame-Options이 사라진다
    }
}

이걸 모르고 location에 헤더 하나 추가했다가 보안 헤더 전체가 빠지는 사고를 본 적이 있다. 해결은 단순하다. location 블록 안에서 보안 헤더를 전부 다시 박거나, include 파일로 빼서 재사용한다. 나는 /etc/nginx/snippets/security-headers.conf에 묶어두고 필요한 곳마다 include 한다.

Express 설정

Node.js 진영은 helmet 미들웨어가 사실상 표준이다. 직접 헤더를 박는 것보다 helmet 쓰는 게 압도적으로 편하다. 작성 시점(2026-06-05 기준) helmet v8이 최신이다.

// app.js
import express from 'express';
import helmet from 'helmet';

const app = express();

app.use(
  helmet({
    // HSTS: 1년, 서브도메인 포함
    strictTransportSecurity: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    // iframe 차단
    frameguard: { action: 'deny' },
    // nosniff
    noSniff: true,
    // Referer 정책
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    // CSP는 서비스 정책에 맞게 따로 짜야 함
    contentSecurityPolicy: false,
  })
);

그러나, helmet은 기본값이 이미 보수적이다. 옵션 안 넘기고 app.use(helmet())만 박아도 14개 정도 헤더가 자동으로 붙는다. 다만 CSP 기본값이 default-src 'self'라 외부 CDN, 인라인 스크립트가 전부 막혀서 첫 배포 때 화면이 깨진다. CSP만 false로 빼고 따로 설정하는 패턴이 안전하다.

이처럼, Permissions-Policy는 helmet v7부터 기본 옵션에서 빠졌다. 직접 박아야 한다.

app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=(), interest-cohort=()'
  );
  next();
});

한편, helmet 공식 문서(helmetjs.github.io)에 옵션 목록이 다 있다. 버전 올라갈 때마다 기본값이 바뀌는 편이라 업그레이드 노트를 한 번씩 봐야 한다.

새로 알게 된 것

그러나, 검증은 curl -I 또는 securityheaders.com이 정석이다. 그런데 securityheaders.com은 외부 노출 도메인만 스캔 가능하다. 사내 망에서 도는 서비스는 못 본다. 이번에 알게 된 건데, Mozilla Observatory도 같은 역할을 하지만 CSP 권장 강도가 더 빡세다. 같은 도메인을 두 곳에 돌려보면 점수가 한 등급씩 다르게 나온다. securityheaders는 A인데 Observatory는 B-, 이런 식이다.

또한, 또 하나, X-XSS-Protection은 이제 빼라. 크롬과 엣지가 2020년경 지원을 끊었고 helmet도 v5부터 기본에서 제외했다. 옛날 글 따라 박았다가 securityheaders에서 별 점수가 안 오르는 이유가 이거다. 박혀 있으면 무해하지만 권장하지 않는다는 게 현재 컨센서스다(MDN 기준).

마지막으로 HSTS preload는 한번 등록하면 빼기가 정말 어렵다. max-age만 길게 걸어두는 건 언제든 0으로 되돌릴 수 있지만, preload 리스트에 들어가면 브라우저 코드에 박혀서 회수까지 몇 달 걸린다. 서브도메인 전체를 HTTPS로 안 옮긴 상태에서 includeSubDomains; preload를 켜면 사고 난다. 메인 도메인 하나만 HTTPS인데 dev.example.com이 HTTP라면 그 순간 dev가 접근 불가가 된다.

메모

이처럼, 오늘 박은 설정으로 securityheaders.com 등급이 D에서 A로 올라갔다. CSP는 일부러 안 건드렸고, 그게 A+로 못 간 유일한 이유다. 다음엔 CSP를 nonce 기반으로 짜서 A+ 등급을 받아볼 생각이다.

관련 글