MYMONDAY.ai
서비스스튜디오도구데이터·포맷73CSS 생성기19색상·이미지10인코딩·보안10웹·SEO11개발·데브옵스10파일·내보내기18계산기6가이드

10분 읽기 · 마지막 업데이트

보안 헤더 설정 방법: HSTS, Content-Security-Policy 등 실무 적용 가이드

보안 헤더는 서버 설정 몇 줄이지만, 잘못 넣으면 브라우저가 몇 달 동안 내 사이트를 열지 못할 수도 있습니다. 시작용 헤더 세트와 값마다의 이유, 켜는 순서를 정리했습니다.

Strict-Transport-Security: 기억하는 헤더

Strict-Transport-Security(HSTS, RFC 6797)는 브라우저에게 이 호스트와는 HTTPS로만 통신하라고 알리는 헤더입니다. 마지막으로 이 헤더를 받은 시점부터 max-age초 동안 브라우저는 http:// 링크를 요청을 보내기 전에 https://로 바꾸고, 인증서 오류도 사용자가 무시하고 진행하지 못하게 막습니다. 평문 HTTP로 받은 HSTS 헤더는 무시되므로, 첫 안전한 응답을 받은 뒤부터 보호가 시작됩니다. HTTP에서 HTTPS로 보내는 리디렉션이 여전히 필요한 이유입니다.

includeSubDomains는 규칙을 모든 하위 도메인으로 넓힙니다. 잊고 있던 사내 대시보드, 스테이징 호스트, 외주 업체가 운영하는 HTTP 전용 status.나 mail. 사이트도 포함됩니다. 도메인 아래의 호스트 이름을 전부 파악한 뒤에 붙이세요. max-age=31536000은 1년이고, max-age=0을 받은 브라우저는 HSTS를 해제합니다.

브라우저가 값을 기억하므로 처음부터 2년으로 시작하지 말고 단계적으로 올리세요. max-age=300으로 하루, 이어서 86400, 일주일, 한 달, 여섯 달, 마지막에 1년으로 갑니다. 초반 단계에서 문제가 생기면 피해가 몇 분으로 끝나고 몇 달씩 가지 않습니다.

# 1단계: 5분, 하위 도메인 제외
Strict-Transport-Security: max-age=300

# 4단계: 1년, 모든 하위 도메인이 유효한 HTTPS를 제공한 뒤
Strict-Transport-Security: max-age=31536000; includeSubDomains

HSTS preload: 요구 조건과 서두르면 안 되는 이유

브라우저가 아직 HTTPS 사용을 모르는 상태라면 방문자의 첫 요청은 여전히 평문 HTTP입니다. preload 목록은 이 빈틈을 막습니다. 브라우저에 처음부터 내장된 목록에 올라간 도메인은 첫 요청부터 HTTPS로만 접속됩니다. 목록은 hstspreload.org를 통해 관리되고 등록 조건이 구체적입니다. 유효한 인증서, 같은 호스트에서의 HTTP에서 HTTPS 리디렉션, 모든 하위 도메인의 HTTPS, 그리고 기본 도메인의 HSTS 헤더에 31536000 이상의 max-age와 includeSubDomains, preload가 있어야 합니다.

preload는 RFC 6797에 없는 항목이고, preload 토큰은 목록 관리자들이 이해하는 관례입니다. 위험은 한쪽으로 치우쳐 있습니다. 등록 신청은 빠르지만, 빼려면 삭제를 요청하고 브라우저 릴리스가 그 변경을 담아 배포될 때까지 기다려야 하며, 이전 버전 브라우저를 쓰는 사용자에게는 옛 목록이 남습니다. 그 기간 동안 도메인 아래의 HTTP 전용 하위 도메인은 해당 브라우저에서 접속할 수 없고 경고를 넘기는 선택지도 없습니다.

미래의 하위 도메인과 내부 도구까지 포함해 도메인 전체가 앞으로도 HTTPS를 제공한다고 확신할 때만 preload 하세요. 확신이 없다면 includeSubDomains를 붙인 1년짜리 HSTS 헤더만으로도 이점 대부분을 얻으면서 긴 꼬리 위험은 피할 수 있습니다.

  • 먼저: 모든 하위 도메인을 조사하고, 인증서를 정비하고, 어디서나 HTTPS를 제공한 뒤 preload 없이 긴 HSTS 기간을 운영합니다.
  • 그다음: 같은 헤더에 preload를 추가하고 도메인을 신청합니다.
  • 금지: 운영 헤더를 복사해 온 스테이징 설정에 preload를 두는 것. 브라우저는 둘을 구분하지 못합니다.

Content-Security-Policy: 위험한 기본값부터 막는 시작 정책

CSP는 세미콜론으로 구분한 지시문의 목록입니다. 가져오기 지시문(script-src, style-src, img-src, connect-src 등)은 해당 종류의 리소스를 어디서 불러올 수 있는지 정하고, default-src는 따로 적지 않은 것들의 대체값입니다. 우선 설정할 가치가 큰 것은 대체값이 없는 지시문들입니다. object-src 'none'은 플러그인 콘텐츠를 막고, base-uri는 주입된 <base> 태그가 모든 상대 URL을 가로채는 것을 막으며, frame-ancestors는 누가 내 페이지를 삽입할 수 있는지 정합니다.

upgrade-insecure-requests는 http:// 하위 리소스를 HTTPS로 가져오게 하므로 오래된 혼합 콘텐츠 링크를 빠르게 해결합니다. 스크립트, 스타일, 이미지가 모두 자기 출처에서 오는 사이트라면 default-src 'self'에 위 네 가지 지시문을 더한 정책이 무난한 출발점입니다.

가치의 대부분은 script-src에서 나옵니다. 크로스 사이트 스크립팅은 스크립트 실행이 있어야 성립하기 때문입니다. 흔한 실패는 호스트 허용 목록을 길게 적고, 사이트가 다시 동작하도록 'unsafe-inline'을 추가하는 것입니다. 이 조합은 주입된 인라인 스크립트도 실행하게 하므로 보호가 사라집니다. 검사기는 nonce나 해시가 없는 스크립트 지시문의 'unsafe-inline'을 오류로 평가하고, 스크립트 가젯이 호스팅되는 것으로 알려진 공용 CDN 호스트를 허용 목록에 둔 경우도 경고합니다.

Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests

'unsafe-inline' 대신 nonce, 해시, 'strict-dynamic'

nonce는 응답마다 새로 만드는 무작위 값입니다. 헤더에 'nonce-값'으로 넣고, 신뢰하는 모든 <script nonce="값">에 같은 값을 붙입니다. 주입된 스크립트에는 nonce가 없으므로 브라우저가 거부합니다. 값은 예측할 수 없어야 하고 응답마다 달라야 합니다. 권장되는 무작위성은 128비트 이상이며 Base64로 22자입니다. CSP 평가기는 이보다 짧은 nonce를 경고하고, 캐시된 HTML에 박힌 고정 nonce는 의미가 없습니다.

정적 페이지에는 해시가 맞습니다. script-src에 'sha256-...'을 넣으면 텍스트가 그 다이제스트와 정확히 일치하는 스크립트만 실행됩니다. 인라인 스크립트 console.log("ready");의 다이제스트는 'sha256-1eibjuhpRz9UB9uxgoXzIqDUOe6eJEAyeM72MwtRe4M='이며, 태그 사이의 바이트를 SHA-256으로 계산해 Base64로 인코딩한 값입니다. 글자 하나만 달라져도 해시가 바뀌므로 거의 바뀌지 않는 스크립트에 어울립니다.

'strict-dynamic'은 다음 문제를 풉니다. 신뢰하는 스크립트가 다른 스크립트를 불러오는 경우입니다. 이 키워드가 있으면 유효한 nonce나 해시가 붙은 스크립트가 추가한 스크립트도 신뢰되고, 이를 이해하는 브라우저는 그 지시문의 호스트 허용 목록, 'self', 'unsafe-inline'을 무시합니다. 그래서 아주 오래된 브라우저용 대체로 'unsafe-inline'이나 https:를 남겨 둘 수 있고, nonce나 해시가 함께 있으면 평가기도 이를 참고 정보로만 표시합니다. nonce나 해시 없는 'strict-dynamic'은 아무것도 신뢰하지 않으므로 평가기가 경고합니다.

정책평가기 결과이유
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net오류 1건, 경고 다수인라인 스크립트 허용, 공용 CDN 호스트는 알려진 우회 경로, object-src와 base-uri 미설정
default-src 'self'오류 0건, 경고 3건스크립트 nonce는 필요 없으나 object-src, base-uri, frame-ancestors가 열려 있음
script-src 'strict-dynamic' https:에 object-src, base-uri, frame-ancestors 추가오류 0건, 경고 1건nonce나 해시 없는 strict-dynamic은 아무것도 신뢰하지 않음
script-src 'nonce-q8Zt3vN1xKp7Wd2LrE5HaA' 'strict-dynamic'에 object-src, base-uri, frame-ancestors 추가오류 0건, 경고 0건엄격한 CSP이며 form-action에 대한 참고 항목만 남음

사이트를 깨지 않고 CSP 적용하기: Report-Only부터

초안은 Content-Security-Policy-Report-Only로 보내세요. 브라우저가 정책을 평가해 위반을 콘솔과 보고 엔드포인트에 알리지만 아무것도 차단하지 않습니다. 실제 트래픽에서 1~2주 돌리며 위반(onclick= 같은 인라인 핸들러, 서드파티 태그, 글꼴, 분석 도구)을 고친 다음 헤더 이름을 Content-Security-Policy로 바꿉니다. 둘을 동시에 보낼 수도 있습니다. 적용 중인 정책은 보수적으로 두고, 더 엄격한 후보를 report-only로 시험하는 방식입니다.

보고에는 예전 방식인 report-uri 지시문이 있고 URL을 받습니다. 새로운 경로는 Reporting API로, Reporting-Endpoints 헤더에 엔드포인트 이름을 선언하고 정책에서 report-to로 참조합니다. 두 방식의 브라우저 지원은 시기마다 달랐으므로 자기 트래픽에서 확인하기 전까지는 둘 다 보내는 사이트가 많습니다. 어느 쪽이든 보고서에는 페이지 URL이 들어 있고 노이즈가 많다는 점을 기억하세요. 브라우저 확장 프로그램이 주입한 스크립트도 위반처럼 보이므로 blocked-uri와 source-file로 걸러서 보세요.

report-only 정책은 보호 효과가 없다는 점에 유의하세요. 이 사이트의 헤더 검사기는 Content-Security-Policy-Report-Only만 있는 응답을 경고로 보고 CSP 점수의 절반만 줍니다. 보고만 할 뿐 강제하지 않기 때문입니다.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"

프레임 삽입과 MIME 스니핑: frame-ancestors, X-Frame-Options, nosniff

클릭재킹은 내 페이지를 다른 사이트의 보이지 않는 프레임에 띄워 놓고 클릭을 가로채는 공격입니다. 요즘 방어 수단은 CSP 지시문 frame-ancestors입니다. 'none'은 모든 삽입을 금지하고, 'self'는 같은 출처만, 출처 목록은 지정한 파트너만 허용합니다. X-Frame-Options(DENY 또는 SAMEORIGIN)는 같은 목적의 오래된 헤더입니다. ALLOW-FROM 형식은 일관되게 지원된 적이 없어 지금은 쓰지 않고, 여러 출처를 지정할 수도 없습니다. frame-ancestors를 지원하는 브라우저에서는 이 지시문이 우선하며, 함께 X-Frame-Options를 보내도 해가 없고 오래된 브라우저를 커버합니다.

X-Content-Type-Options: nosniff는 유효한 값이 하나뿐입니다. 브라우저가 선언된 Content-Type과 다른 타입을 추측하지 못하게 하고, 스크립트나 스타일시트가 해당하지 않는 타입으로 제공되면 로드를 거부하게 합니다. 이 헤더 때문에 문제가 생기는 전형적인 경우는 JavaScript인데 text/plain이나 빈 타입으로 내려가는 파일입니다. 헤더를 빼지 말고 서버의 MIME 매핑을 고치세요.

  • 정당한 삽입이 없는 앱은 frame-ancestors 'none', 자기 페이지를 프레임으로 쓰는 사이트는 'self'.
  • X-Frame-Options: DENY는 'none'에 대응하는 선택적 레거시 헤더이고, 'self'에는 SAMEORIGIN이 대응합니다.
  • 이 사이트의 헤더 검사기는 어느 쪽이든 프레임 검사를 통과시키지만, frame-ancestors에 구체적인 출처가 있고 *가 없을 때만 CSP 쪽으로 인정합니다.

Referrer-Policy, Permissions-Policy, COOP, 그리고 쓸모없어진 X-XSS-Protection

Referrer-Policy는 나가는 요청의 Referer 헤더에 현재 URL을 얼마나 담을지 정합니다. strict-origin-when-cross-origin은 같은 출처 요청에는 전체 URL을, 다른 사이트에는 출처만 보내고, HTTPS에서 HTTP로 가는 경우에는 아무것도 보내지 않습니다. 가장 흔한 기본값이면서 명시적으로 지정하기에도 좋은 선택입니다. no-referrer는 아무것도 보내지 않아 이를 필요로 하는 분석 도구나 외부 로그인이 깨질 수 있고, unsafe-url은 토큰이 들어 있을 수 있는 경로와 쿼리 문자열까지 어디에나 전체 URL을 보내므로 피해야 할 정책입니다.

Permissions-Policy는 페이지와 그 프레임에서 쓸 수 있는 브라우저 기능을 제한합니다. 구문은 구조화된 헤더로, 기능=(허용 목록) 형태이고 괄호 안의 항목은 공백으로, 기능끼리는 쉼표로 구분합니다. 빈 목록 ()은 기능을 끄고, (self)는 내 출처만, (self "https://maps.example.com")처럼 따옴표로 다른 출처를 더할 수 있습니다. 쓰지 않는 기능부터 끄세요. camera=(), microphone=(), geolocation=()이 그 예입니다. 구문이 달랐던 예전 Feature-Policy를 대체합니다.

Cross-Origin-Opener-Policy: same-origin은 내 창과 다른 출처의 팝업이나 오프너 사이의 연결을 끊습니다. 창 사이의 공격을 막아 주고 교차 출처 격리의 요건 중 하나입니다. 대가는 window.opener에 의존하는 팝업 흐름, 예를 들어 일부 OAuth나 결제 창이 동작하지 않게 되는 것입니다. 직접 그런 팝업을 여는 페이지에는 same-origin-allow-popups가 절충안입니다.

X-XSS-Protection은 브라우저가 이미 없앤 필터를 위한 헤더입니다. Chrome과 Edge는 XSS 감사기를 제거했고 Firefox는 처음부터 넣지 않았으며, 필터 자체가 정보 유출에 악용될 수 있었기 때문에 요즘의 권고는 헤더를 생략하거나 0을 보내는 것입니다. 제대로 된 CSP가 그 역할을 합니다. 헤더 검사기는 이 헤더를 채점하지 않습니다.

시작용 헤더 세트와 값별 역할

아래 세트는 뒤의 채점 예시에 쓴 것입니다. 자기 스크립트를 제공하고 응답마다 새 nonce를 만드는 사이트를 전제로 하며, 예시의 nonce는 요청마다 만든 자기 값으로 바꾸세요.

헤더목적안전한 시작 값
Strict-Transport-Security호스트에 HTTPS 강제max-age=300에서 시작해 31536000까지 단계적으로, 모든 하위 도메인이 준비되면 includeSubDomains
Content-Security-Policy불러오고 실행할 수 있는 것을 제한Report-Only로 시작: default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
X-Content-Type-OptionsMIME 추측 차단nosniff
Referrer-Policy다른 사이트로의 URL 노출 제한strict-origin-when-cross-origin
Permissions-Policy쓰지 않는 브라우저 기능 비활성화camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy다른 출처의 오프너로부터 창 격리same-origin (서드파티 팝업을 열면 same-origin-allow-popups)
Cross-Origin-Resource-Policy리소스를 삽입할 수 있는 대상 제어비공개 자원은 same-origin, 공개 자원은 cross-origin
X-Frame-Options레거시 클릭재킹 방어frame-ancestors와 함께 선택적으로: DENY 또는 SAMEORIGIN
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'nonce-q8Zt3vN1xKp7Wd2LrE5HaA' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin

검사기와 평가기는 이 세트를 어떻게 채점하나

위 시작 세트를 헤더 검사기에 붙여 넣으면 100점 만점에 95점, A등급입니다. CSP 25/25, HSTS 20/20이고 nosniff, 프레임, Referrer-Policy, COOP가 통과하며, 빠진 5점은 Cross-Origin-Resource-Policy입니다. 값을 하나씩 바꿔 보면 가중치를 알 수 있습니다. CSP를 Report-Only로 바꾸면 CSP가 절반만 인정되고, 강제되는 frame-ancestors에 의존하던 프레임 검사가 0점이 되어 72점, C등급으로 떨어집니다. HSTS를 max-age=86400으로 하면 85점, B등급입니다. Referrer 값을 no-referrer-when-downgrade로 하면 약한 값이 절반만 인정되어 90점입니다. X-XSS-Protection을 추가해도 변화가 없습니다.

이 사이트의 CSP 평가기는 정책 자체를 읽습니다. nonce와 strict-dynamic 정책은 오류도 경고도 없고, 엄격한 CSP가 감지되었다는 것과 form-action이 없다는 참고 항목만 나옵니다. 헤더 검사기도 이 결과를 점수에 반영합니다. 오류와 경고가 모두 없는 CSP는 25점을 전부 받고, 평가기 경고는 1건마다 3점이 깎이되 CSP 점수는 25점 중 13점 아래로 내려가지 않습니다. 오류가 하나라도 있으면 12점으로 제한되고 Report-Only도 12점이므로, 강제되는 정책이 정책 없음보다 항상 높게 나옵니다. default-src 'self' 한 줄만 있으면 경고가 3건(object-src, base-uri, frame-ancestors)이라 16/25점이고, frame-ancestors가 없어 프레임 검사도 0점이 됩니다. 시작 세트의 CSP를 이 한 줄로 바꾸면 95점에서 76점, B등급으로 내려갑니다. 두 도구를 모두 돌리고 평가기의 경고를 해야 할 일 목록으로 삼으세요.

설정 파일이 아니라 배포된 응답을 검사하세요. CDN, 로드 밸런서, 프레임워크가 헤더를 추가하거나 바꾸거나 병합할 수 있고, HTML 경로에 설정한 헤더가 API 응답, 리디렉션, 오류 페이지에도 붙는다는 보장은 없습니다.

security.txt: 제보 창구 알리기

RFC 9116은 보안 연구자에게 연락 방법을 알려 주는 텍스트 파일 /.well-known/security.txt를 정의합니다. 필수 필드는 두 개로, mailto:, tel:, https: URI인 Contact와 2027-06-30T23:59:59Z 같은 전체 날짜-시간 형식의 Expires입니다. 2026-09-01처럼 시간이 없는 날짜는 유효한 RFC 3339 날짜-시간이 아니므로 검증기가 오류로 표시하고, 과거 날짜도 마찬가지입니다. 선택 필드로 Canonical, Policy, Encryption(키 자체가 아니라 키로 가는 링크), Acknowledgments, Preferred-Languages가 있습니다.

Expires는 1년 이내로 설정하고 일정에 갱신 알림을 넣어 파일이 낡지 않게 하세요. 검증기는 1년을 넘으면 경고합니다. 파일은 HTTPS와 Content-Type: text/plain으로 제공합니다. 최소한의 파일은 몇 줄이며, 검증기는 PGP 서명이 없다는 참고 항목 외에는 유효하다고 보고합니다.

Contact: mailto:security@example.com
Contact: https://example.com/report
Expires: 2027-06-30T23:59:59Z
Preferred-Languages: en, ko
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/security-policy

직접 해보기