Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유
Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.
그래서 지금은 헤더를 프록시가 아니라 next.config.ts 에 둡니다. 앱이 어디로 가든 헤더가 같이 갑니다. 오늘 공개 상태 페이지(ops.duolabs.co.kr)를 새로 만들면서 그 설정을 또 한 벌 넣었는데, 매번 같은 줄을 넣으면서도 왜 그 값인지는 여러 리포의 주석에 흩어져 있어서 한 번 모아 적습니다.
헤더가 앱과 함께 다녀야 하는 이유
프록시에 둔 설정은 프록시가 바뀌면 사라집니다. 저희는 그걸 실제로 겪었습니다. 오리진 앞에 nginx 가 있을 때 거기서 보안 헤더를 붙였고, Vercel 로 옮기면서 nginx 가 경로에서 빠졌습니다. 헤더가 빠졌다는 사실을 알려 주는 것은 아무것도 없었습니다.
next.config.ts 의 headers() 에 두면 배포 대상이 Docker 든 Vercel 이든 같은 헤더가 나갑니다. 프록시가 한 겹 더 붙어도 앱이 보내는 헤더는 그대로 지나갑니다. 한 벌만 관리하면 되고, 코드 리뷰에서 보입니다.
지금 새 프로젝트마다 들어가는 것은 이 다섯 줄과 CSP 한 덩어리입니다.
| 헤더 | 값 |
|---|---|
| Content-Security-Policy | 아래 절 |
| X-Content-Type-Options | nosniff |
| X-Permitted-Cross-Domain-Policies | none |
| Referrer-Policy | strict-origin-when-cross-origin |
| Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=(), usb=() |
CSP 에 'unsafe-inline' 을 남긴 채로 시작하는 이유
script-src 에 'unsafe-inline' 이 있으면 CSP 를 넣은 의미의 절반이 사라진다는 말을 많이 합니다. 맞는 말이고, 그래도 저희 기본값에는 들어 있습니다. Next.js 가 페이지를 띄울 때 인라인 스크립트로 부트스트랩을 하기 때문입니다.
정석은 요청마다 nonce 를 만들어 그 인라인 스크립트에 붙이는 것입니다. 그런데 그건 페이지가 요청마다 렌더될 때만 됩니다. 정적으로 만들어 CDN 에 굳혀 두는 페이지에는 요청마다 다른 nonce 를 넣을 수 없습니다. 오늘 만든 상태 페이지가 정확히 그런 페이지라, 거기서는 'unsafe-inline' 을 빼는 대신 나머지를 전부 좁혔습니다.
default-src 'self' 로 시작해서 frame-src 'none', object-src 'none', base-uri 'self' 를 두고, 밖으로 나가는 요청은 connect-src 'self' 로 막습니다. 스크립트 한 줄이 어디선가 끼어들어도 그 스크립트가 데이터를 밖으로 보낼 길은 없습니다. 'unsafe-inline' 하나 때문에 CSP 전체를 포기하는 것보다 이쪽이 낫다고 봅니다.
frame-ancestors 가 X-Frame-Options 를 대신하는 자리
클릭재킹을 막는 헤더로 X-Frame-Options 를 먼저 떠올리는데, 저희는 넣지 않습니다. CSP 의 frame-ancestors 가 같은 일을 하고 더 세밀하게 정할 수 있습니다. 둘 다 있으면 브라우저는 frame-ancestors 를 따르고 X-Frame-Options 를 무시하게 돼 있어서, 두 값을 관리할 이유가 없습니다.
값은 'self' https://duolabs.co.kr https://*.duolabs.co.kr 입니다. 회사 랜딩이 각 제품의 데모를 iframe 모달로 띄우기 때문에 랜딩 도메인을 열어 둬야 합니다. 이걸 'none' 으로 잠그면 랜딩에서 데모 버튼을 눌렀을 때 빈 창이 8초 동안 떠 있다가 「새 탭에서 열기」로 떨어집니다. 어느 쪽이 막았는지는 화면에 안 나옵니다. 새 프로젝트에서 이 줄을 빼먹으면 랜딩 쪽에서 먼저 알게 됩니다.
HSTS 를 넣지 않는 이유
Strict-Transport-Security 는 목록에 없습니다. Vercel 이 이미 max-age=63072000, 2년짜리를 보내고 있어서입니다. 같은 헤더가 둘 오면 브라우저는 첫 번째 것만 보게 돼 있고(RFC 6797 8.1), 어느 것이 첫 번째가 되는지는 플랫폼이 정합니다. 저희가 더 짧은 값을 하나 더 보내서 얻는 것은 없고, 그게 채택될 가능성만 생깁니다.
다만 이건 Vercel 에 있을 때만 참입니다. 같은 앱을 자체 프록시 뒤로 옮기면 그 프록시가 HSTS 를 보내는지 확인해야 하고, 안 보내면 그때는 목록에 들어갑니다.
설명이 한 줄로 끝나는 넷
X-Content-Type-Options: nosniff 는 브라우저가 응답의 Content-Type 을 믿게 합니다. 이게 없으면 텍스트로 올린 파일을 브라우저가 HTML 로 추측해서 실행할 수 있습니다.
Referrer-Policy: strict-origin-when-cross-origin 은 다른 도메인으로 갈 때 경로를 떼고 origin 만 보냅니다. 내부 URL 에 담긴 정보가 외부 링크의 로그에 남지 않게 하려는 것입니다.
Permissions-Policy 는 카메라, 마이크, 위치, 결제, USB 를 전부 끕니다. 쓰는 기능이 생기면 그 항목만 엽니다. 끄는 것이 기본이고 여는 것이 예외입니다.
X-Powered-By 는 poweredByHeader: false 로 끕니다. 「Next.js 입니다」를 응답마다 광고할 이유가 없습니다.
*.vercel.app 을 색인에서 빼는 방법이 canonical 이 아닌 이유
Vercel 에 올리면 운영 도메인 말고도 프로젝트.vercel.app 주소가 생기고, 프리뷰 배포마다 주소가 하나씩 더 생깁니다. 전부 같은 내용을 냅니다. 검색 엔진이 그걸 따로 색인하면 같은 글이 두 주소로 잡히고, 어느 쪽이 원본인지를 검색 엔진이 정하게 됩니다.
<link rel="canonical"> 로 원본을 알려 주는 방법이 먼저 떠오르는데, canonical 은 힌트이지 명령이 아닙니다. 검색 엔진이 다른 판단을 할 수 있습니다. 원 서버가 직접 거절해야 확실합니다.
그래서 next.config.ts 의 headers() 에 host 매처를 걸어, 요청의 host 가 .vercel.app 으로 끝나면 X-Robots-Tag: noindex, nofollow 를 붙입니다. 운영 도메인으로 들어온 요청에는 붙지 않습니다. 프리뷰 배포도 같은 규칙에 걸립니다.
{
source: "/(.*)",
has: [{ type: "host", value: ".*\\.vercel\\.app" }],
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
}외부 리소스가 없는 앱을 더 좁히는 이유
오늘 만든 상태 페이지는 외부 이미지도, 폼도, 클라이언트에서 부르는 API 도 없습니다. 그래서 형제 프로젝트의 기본값에서 두 줄을 더 조였습니다. img-src 에서 https: 를 빼고, form-action 을 'self' 에서 'none' 으로 바꿨습니다.
기본값을 복사해서 그대로 두는 편이 편합니다. 그런데 그 앱이 안 쓰는 권한을 열어 둔 것은 그냥 열려 있는 것입니다. 항목을 여는 일이 생기면 그때 그 한 줄만 열면 됩니다. 반대로 나중에 좁히려면 무엇이 어디서 쓰이는지 다시 다 봐야 합니다.
차단 규칙이 앱을 깨는 순간
헤더가 아니라 프록시의 차단 규칙 이야기인데, 교훈이 같아서 같이 적습니다. 2026-08-17 에 리버스 프록시에서 /vendor/* 경로를 404 로 끊는 규칙을 넣었습니다. PHP 앱의 vendor 디렉터리를 노리는 스캐너를 막으려는 것이었습니다.
넣자마자 지도가 깨졌습니다. 저희 앱 둘이 MapLibre 를 /vendor/maplibre/ 아래에서 서빙하고 있었습니다. 규칙을 뺐고, 같이 넣었던 wp-login.php 류의 워드프레스 규칙도 뺐습니다. 저희에게 PHP 앱이 하나도 없어서, 없는 것을 막아 얻는 이득은 없고 오탐 위험만 커진다는 것을 그때 알았습니다.
보안 설정은 넣는 순간에는 아무것도 안 깨지는 것처럼 보입니다. 깨지는 것은 그 경로를 실제로 쓰는 화면이고, 그 화면은 보통 다른 사람이 엽니다. 규칙을 넣기 전에 「우리 앱이 이 경로를 쓰는가」를 먼저 찾아보는 것이 지금은 순서입니다.
함께 읽기
- PDF 암호화: 권한 암호만 걸면 그냥 열리는 이유권한 암호가 걸린 PDF 를 qpdf 로 들여다보면 이런 모양이 나옵니다.
- 전자서명의 효력과 증거력: 유효한 것과 증명되는 것의 차이전자서명 도입을 검토하면 거의 항상 같은 질문이 먼저 나옵니다. "이거 법적으로 효력 있나요?"
- 전자서명 구현: 손글씨보다 문서 동결이 먼저인 이유전자서명 서비스를 처음 보면 가장 눈에 띄는 것은 화면에 손가락이나 마우스로 사인을 그리는 부분입니다. 계약이 그 그림으로 성립하는 것처럼 보입니다.
- API 키는 왜 다시 볼 수 없을까: 발급·검증·권한이 갈리는 자리GitHub 에서 토큰을 만들면 화면에 한 번 뜨고 다시는 볼 수 없습니다. Stripe 도 AWS 도 같습니다. 복사를 놓치면 방법이 없고, 새로 만들라는 안내만 나옵니다.
- 개인 액세스 토큰의 한계: 토큰은 권한이 아니라 신원- run: eas build --platform all --non-interactive