Claude Code로 Web 보안 헤더 설정하기: CSP, nonce, HSTS, 광고 충돌까지
Claude Code로 CSP, nonce, HSTS, frame-ancestors를 설계하고 Next.js, Astro, Express, Cloudflare에서 검증하는 실전 가이드.
Web 보안 헤더는 눈에 잘 띄지 않지만, 실제 서비스의 위험을 크게 줄이는 장치입니다. 브라우저가 어떤 스크립트를 실행해도 되는지, 관리자 페이지가 다른 사이트의 iframe 안에 들어가도 되는지, 외부 링크를 클릭할 때 전체 URL을 넘겨도 되는지, 카메라나 위치 정보 같은 기능을 페이지에 허용할지 모두 HTTP 응답 헤더에서 제어할 수 있습니다.
Claude Code는 이런 설정을 빠르게 작성할 수 있습니다. 하지만 지시가 모호하면 위험한 완화책도 그럴듯하게 만들어 냅니다. 예를 들어 “CSP 오류를 고쳐줘”라고만 요청하면 script-src * 'unsafe-inline' 'unsafe-eval' 같은 넓은 정책을 제안할 수 있습니다. 콘솔 오류는 사라지지만 CSP가 막아야 할 공격도 거의 막지 못합니다.
이 글은 2026년 6월 기준으로 무리 없는 Web 보안 헤더 운영 방식을 정리합니다. CSP, nonce, HSTS, X-Frame-Options, frame-ancestors, Referrer-Policy, Permissions-Policy를 설명하고, Next.js, Astro, Express, Cloudflare Pages 설정 예시를 제공합니다. CSP report, Security Headers, CSP Evaluator 검증, Google Tag Manager, Google Analytics, AdSense, 이미지 CDN 충돌도 함께 다룹니다. Claude Code 자체의 안전한 운영은 Claude Code 보안 베스트 프랙티스와 보안 감사 가이드도 같이 보세요.
기준 문서는 원문을 확인하는 것이 좋습니다. 이 글은 MDN Content-Security-Policy, Next.js CSP guide, MDN Strict-Transport-Security, hstspreload.org, Cloudflare Pages Headers, Helmet, Google Tag Manager CSP, AdSense CSP 안내를 기준으로 삼았습니다.
헤더를 쓰기 전에 로딩 자원을 먼저 파악하기
보안 헤더는 복사한 예시로 시작하면 안 됩니다. 사이트가 실제로 어떤 외부 리소스를 쓰는지 먼저 알아야 합니다. Claude Code에는 아래처럼 조사부터 시키는 편이 안전합니다.
이 저장소의 Web 보안 헤더를 설계해 주세요.
조건:
- script, style, image, font, frame, connect 외부 출처를 먼저 목록화한다
- CSP는 처음에 Report-Only로 배포한다
- * 와 상시 unsafe-inline 사용을 피한다
- Next.js nonce가 필요한 경우 동적 렌더링과 캐시 영향을 설명한다
- Google Analytics, GTM, AdSense, 이미지 CDN, YouTube iframe 충돌을 확인한다
- curl, Security Headers, CSP Evaluator 검증 절차를 제시한다
특히 frame-src와 frame-ancestors를 구분해야 합니다. frame-src는 내 페이지가 어떤 iframe을 불러올 수 있는지 제어합니다. frame-ancestors는 내 페이지를 누가 iframe으로 감쌀 수 있는지 제어합니다. 클릭재킹 방어는 주로 frame-ancestors입니다. GA4 전송 실패는 보통 connect-src, CDN 이미지 실패는 img-src에서 찾습니다.
| 헤더 | 시작점 | 주의할 점 |
|---|---|---|
Content-Security-Policy | 먼저 Content-Security-Policy-Report-Only | *나 상시 unsafe-inline로 덮지 않기 |
Strict-Transport-Security | max-age=300; includeSubDomains부터 | preload는 모든 하위 도메인 HTTPS 확인 후 |
X-Frame-Options | DENY 또는 SAMEORIGIN | 현대 브라우저는 CSP frame-ancestors가 더 세밀함 |
Referrer-Policy | strict-origin-when-cross-origin | URL에 민감한 값을 넣지 않는 설계도 필요 |
Permissions-Policy | 쓰지 않는 기능은 차단 | 필요한 기능만 route 단위로 허용 |
X-Content-Type-Options | nosniff | 대부분 전역 설정해도 부담이 낮음 |
CSP는 관찰 후 강제 적용한다
CSP는 “막기”보다 “먼저 관찰하고 좁히기”가 중요합니다. 처음부터 강제 적용하면 광고, 로그인, 결제, 문의 폼이 동시에 깨질 수 있습니다.
flowchart LR
A["외부 리소스 목록화"] --> B["Report-Only CSP 배포"]
B --> C["브라우저 콘솔과 report endpoint 확인"]
C --> D["광고, 분석, CDN, iframe, 노이즈 분류"]
D --> E["nonce 또는 hash 기반 정책으로 전환"]
E --> F["Security Headers와 CSP Evaluator로 검증"]
Report에 나온 도메인을 모두 허용하지 마세요. 브라우저 확장, 사내 프록시, 오래된 태그, 공격 시도도 report를 만들 수 있습니다. Claude Code는 로그를 분류하는 데 유용하지만, 실제로 필요한 의존성인지는 사람이 판단해야 합니다.
Next.js에서 nonce 기반 CSP 설정
Next.js App Router의 최신 문서는 proxy.ts에서 요청마다 nonce를 생성하는 방식을 설명합니다. 오래된 프로젝트는 middleware.ts를 쓰고 있을 수 있지만 핵심은 같습니다. 예측 불가능한 값을 만들고, 요청 header에 넘기고, CSP의 script-src에도 같은 값을 넣습니다.
nonce 방식은 동적 렌더링과 관련이 있습니다. 요청마다 nonce가 달라야 하므로 완전한 정적 HTML 캐시와 잘 맞지 않습니다. 공개 블로그나 문서 사이트는 hash 기반 CSP나 외부 스크립트 분리를 검토하고, 로그인 후 화면, 결제, 관리자 페이지는 nonce 방식을 우선 고려합니다.
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV !== "production";
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""} https: http:`,
`style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`} https://fonts.googleapis.com`,
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"connect-src 'self' https://www.google-analytics.com https://analytics.google.com",
"frame-src 'self' https://www.youtube-nocookie.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
"report-uri /api/csp-report",
].join("; ").replace(/\s{2,}/g, " ").trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self)");
response.headers.set("Strict-Transport-Security", "max-age=300; includeSubDomains");
return response;
}
export const config = {
matcher: ["/((?!api/csp-report|_next/static|_next/image|favicon.ico).*)"],
};
Google Tag Manager를 쓴다면 nonce를 GTM 컴포넌트나 스니펫에 전달해야 합니다. GTM의 시작 코드는 inline JavaScript이기 때문입니다. AdSense도 CSP를 사용할 때 strict CSP, 즉 nonce 중심 접근을 안내합니다. 광고 도메인은 시간이 지나며 바뀔 수 있으므로 단순 도메인 allowlist만 믿으면 광고 송출이 끊길 수 있습니다.
CSP report 수집하기
Report-Only 정책을 보내려면 report endpoint가 있어야 합니다. Next.js Route Handler에서는 다음처럼 최소 구현을 만들 수 있습니다.
// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const contentType = request.headers.get("content-type") ?? "";
const body = await request.text();
const isReport =
contentType.includes("application/csp-report") ||
contentType.includes("application/reports+json") ||
body.includes("violated-directive");
if (!isReport) {
return NextResponse.json({ ok: false }, { status: 415 });
}
console.warn("csp-report", body.slice(0, 4000));
return new NextResponse(null, { status: 204 });
}
운영 환경에서는 전체 URL과 query string을 무조건 저장하지 마세요. 페이지, 위반 directive, blocked URI, user agent, 시간, 횟수 정도만 보관하는 편이 안전합니다. report-uri는 오래된 방식이지만 호환성 때문에 여전히 실용적입니다. report-to는 추가로 검토할 수 있지만 단독 경로로 의존하기에는 아직 조심스럽습니다.
Astro, Express, Cloudflare 예시
Astro는 middleware에서 고정 헤더를 설정하기 쉽습니다. 정적 사이트는 nonce보다 인라인 스크립트를 줄이고 _headers 또는 middleware로 시작하는 편이 단순합니다.
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
const securityHeaders: Record<string, string> = {
"Content-Security-Policy-Report-Only": "default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: blob: https:; connect-src 'self' https://www.google-analytics.com; frame-src 'self' https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; report-uri /api/csp-report",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(self)",
};
export const onRequest = defineMiddleware(async (_context, next) => {
const response = await next();
for (const [name, value] of Object.entries(securityHeaders)) {
response.headers.set(name, value);
}
return response;
});
Express는 Helmet을 쓰는 것이 현실적입니다. 다만 CSP는 앱마다 달라서 기본값만으로 끝내면 안 됩니다.
import crypto from "node:crypto";
import express from "express";
import helmet from "helmet";
const app = express();
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'", "https:", "http:"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: ["'self'", "https://www.google-analytics.com"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
reportUri: ["/csp-report"],
},
},
strictTransportSecurity: { maxAge: 300, includeSubDomains: true },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xFrameOptions: { action: "deny" },
}));
Cloudflare Pages의 _headers는 정적 헤더에 적합하지만 요청별 nonce를 만들 수 없습니다. strict nonce CSP가 필요하면 Workers, Functions, SSR 또는 hash 기반 CSP를 검토하세요.
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self)
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: blob: https:; connect-src 'self' https://www.google-analytics.com; frame-src 'self' https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; report-uri /csp-report
실제 사용 사례와 흔한 충돌
콘텐츠 사이트는 GA4, GTM, AdSense, 이미지 CDN, YouTube를 동시에 쓰는 경우가 많습니다. 이때 CSP를 너무 빨리 강제하면 광고가 사라지고 분석 이벤트가 끊깁니다. 먼저 Report-Only로 광고 노출, Analytics 이벤트, 이미지 로딩, iframe 로딩을 확인하세요.
SaaS 관리자 화면은 반대로 외부 스크립트를 줄이는 방향이 맞습니다. frame-ancestors 'none', object-src 'none', base-uri 'self', 좁은 form-action이 기본입니다. 결제 SDK나 채팅 위젯은 필요한 route에만 허용합니다.
임베드 위젯은 별도 정책이 필요합니다. 고객 사이트에 iframe으로 들어가야 하는 route에 X-Frame-Options: DENY를 적용하면 제품 기능 자체가 깨집니다. 일반 페이지와 embed route의 헤더를 분리하세요.
HSTS도 자주 사고가 납니다. 첫날부터 preload를 넣기보다 max-age=300부터 시작하고, 모든 하위 도메인과 오래된 도메인이 HTTPS로 동작하는지 확인한 뒤 단계적으로 늘리는 편이 안전합니다.
검증과 실제 테스트 결과
최소한 홈, 로그인 또는 폼, 임베드 또는 결제 페이지를 확인합니다.
curl -I https://example.com/
curl -I https://example.com/login
curl -I https://example.com/embed/widget
그다음 Security Headers로 전체 헤더를 보고, CSP Evaluator로 CSP 약점을 확인합니다. 점수는 참고 자료일 뿐입니다. 실제 목표는 보안을 높이면서 광고, 분석, 결제, 이미지, iframe이 정상 동작하도록 만드는 것입니다.
실제 검증용 구성에서는 Report-Only를 먼저 넣은 것이 가장 효과적이었습니다. GTM inline nonce, GA4 connect-src, YouTube frame-src, 이미지 CDN img-src 문제가 서로 다른 report로 분리되어 보였습니다. Claude Code에 로그를 분류하게 하니 단순히 도메인을 추가하는 대신 route별 CSP, nonce 전달, 불필요한 태그 제거, HSTS 단계 도입으로 정리할 수 있었습니다. 팀에서 이 과정을 실제 저장소에 적용하려면 Claude Code 교육 및 도입 상담이나 템플릿 제품을 활용해 CLAUDE.md 검토 항목으로 고정하는 것이 좋습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.