06. 웹 보안 심화 가이드

0. 먼저 알고 가기 (30초 요약)

  • 인증은 누가인지, 인가는 무엇을 할 수 있는지부터 체크하세요.
  • 입력/출력 단에서 기본 안전장치를 먼저 넣고, 나중에 규칙을 강화하세요.
  • 보안은 한번에 완성되기보다, 로그·감사·차단을 통해 계속 보정합니다.

초심자용 한눈에 보기

보안은 “무엇이 위험한지”보다 “실수했을 때도 깨지 않도록” 관리합니다.

핵심 용어 빠르게 정리

용어 쉬운 뜻
인증 누군지 확인하는 과정
인가 어떤 권한을 할당할지 결정하는 과정
CSP 브라우저에서 실행할 수 있는 스크립트/자원을 제한하는 규칙
토큰 로그인/권한 확인에 쓰는 짧은 암호화 문자열
trusted types DOM에 들어가는 위험한 문자열 주입을 줄이는 브라우저 기능
분류 아키텍처 상태 Stable
연관 가이드 10. 인프라, 05. API 통신, 11. CI/CD 도구 원칙 벤더 중립
핵심 테마 OWASP Top 10:2025, CSP3 + Trusted Types, Passkeys/WebAuthn, RFC 9700 OAuth 2.0 Security BCP, Supply Chain Update 최신 기준

"보안은 기능이 아니라 제품의 전제 조건이다. 보이지 않는 곳에서의 방어 체계가 서비스의 가치를 지킨다." 본 가이드는 현대 프론트엔드 환경에서 발생할 수 있는 주요 보안 위협과 그에 대한 철저한 방어 표준을 제시합니다.


추천 항목 (실무 우선순위)

  • 시작 추천: 인증·권한 체계를 분리하고 토큰 저장 위치와 만료 정책을 먼저 맞춥니다.
  • 안정 추천: CSP, CORS, 보안 헤더 기본값을 템플릿으로 강제화해 누락을 막습니다.
  • 운영 추천: 취약점 스캐닝과 secret 노출 점검을 PR 템플릿의 필수 항목으로 만듭니다.

추천 항목 고도화 체크

  • 첫 적용 — CSP, 인증, 시크릿, 공급망 통제 중 하나를 실제 PR이나 운영 이슈에 붙이고, 변경 전 기준을 먼저 적는다.
  • 증거 정리 — security scan, header capture, secretlint 결과, SBOM/attestation를 같은 작업 기록에 남긴다.
  • 재점검 — 차단된 secret 수, CSP report, 취약 dependency 수가 나아졌는지 30일 안에 확인하고 기준을 유지, 수정, 폐기 중 하나로 판정한다.

추천 항목 실행 기록 템플릿

  • 작업 : CSP, 인증, 시크릿, 공급망 통제 적용 범위를 어느 화면, 패키지, 문서에 둘지 적는다.
  • 증거 : security scan, header capture, secretlint 결과, SBOM/attestation 중 실제로 남긴 항목만 링크한다.
  • 판정 : 유지/수정/폐기 중 하나와 이유를 한 문장으로 남긴다.
  • 다음 점검 : 차단된 secret 수, CSP report, 취약 dependency 수를 다시 볼 날짜와 담당자를 지정한다.

문서 책임 범위

이 문서가 결정하는 것 단일 출처로 따르는 문서
브라우저 보안 모델, CSP, Trusted Types, auth/session, client secret 기준 05. API 통신, 10. 인프라
supply chain, lockfile, SBOM, artifact provenance 보안 기준 11. CI/CD
보안 이벤트 탐지와 incident escalation 09. 관측성
AI 입력에 포함하면 안 되는 데이터와 보안 검증 책임 18. AI 개발 워크플로우

0. 모든 프론트엔드 그룹 공통 Baseline

보안 표준은 특정 보안 제품이 아니라 브라우저 보안 모델, 공급망 무결성, 인증 경계, 운영 탐지를 함께 통제하는 체계입니다.

기준 최소 적용
위협 모델 신규 인증/결제/관리자/외부 입력 기능은 STRIDE 또는 동등한 모델로 위험을 기록합니다.
브라우저 방어 CSP, Trusted Types(가능한 경우), 보안 헤더, 안전한 URL 검증을 기본값으로 둡니다.
공급망 무결성 lockfile 고정, provenance/attestation, dependency audit, license check를 CI에 둡니다.
비밀 관리 client bundle에 secret을 포함하지 않고, public env와 server secret을 분리합니다.
탐지와 대응 auth failure, CSP violation, unusual API error, dependency alert를 관측성 시스템으로 보냅니다.

0.0 보안 방어 레이어

flowchart TD
  A[사용자 입력/요청] --> B{브라우저 보안 경계}
  B -->|출처·형식 검증| C[Validation & Sanitize]
  B -->|리소스 실행 검증| D[CSP / Trusted Types]
  C --> E[인증/인가 + API 호출]
  D --> E
  E --> F[관측성 수집]
  F --> G{이상 징후}
  G -->|감지| H[알림 + 즉시 완화 + 사고 대응]
  G -->|정상| I[안전 종료]
  H --> J[사후 분석 + 룰/정책 갱신]

0.1 교차 검증 매트릭스

권고 1차 출처 실행 증거 운영 증거 철회 조건
OWASP Top 10:2025를 보안 체크리스트의 상위 분류로 사용한다 OWASP Top 10:2025 공식 문서 threat model, SAST/DAST rule mapping 보안 사고 분류, vulnerability aging 프로젝트 도메인이 OWASP ASVS/MASTG 등 더 구체적 표준을 요구할 때
CSP는 report-only에서 시작해 nonce/hash 기반 enforcement로 승격한다 CSP Level 3, MDN/W3C 문서 CSP regression test, violation report 검증 violation rate, XSS near miss 레거시 third-party script가 정책을 만족하지 못해 임시 예외가 필요할 때
dependency와 build artifact는 출처/무결성을 증명한다 SLSA v1.2, NIST SSDF, package manager provenance 문서 lockfile audit, SBOM/provenance artifact dependency incident MTTR 폐쇄망/레거시 registry가 provenance를 지원하지 않을 때
인증은 토큰 저장 위치, 회전, 재인증 UX를 함께 설계한다 RFC 9700 OAuth 2.0 Security BCP, WebAuthn auth flow E2E, CSRF/XSS 테스트 401/403 spike, account takeover signal 위협 모델상 공개 읽기 전용 앱일 때

0.2 운영 게이트

Gate Evidence Owner Rollback
Threat model gate STRIDE/ASVS checklist, risk owner Security owner feature flag off 또는 launch hold
CSP enforcement gate report-only baseline, violation burn-down Platform owner report-only로 되돌리고 예외 ADR 작성
Supply-chain gate lockfile audit, SBOM/provenance artifact Build owner dependency pinning 또는 package rollback
Auth/session gate CSRF/XSS/auth E2E, token rotation test Auth owner previous session strategy 유지

0.3 OWASP Top 10:2025 — 무엇이 달라졌나

OWASP Top 10:2025는 A03에 Software Supply Chain Failures를 추가하고, A10에 Mishandling of Exceptional Conditions를 추가했습니다. 프론트엔드는 이제 XSS와 인증뿐 아니라 패키지 공급망, CI/CD, 예외 처리, 보안 로깅을 같은 수준의 보안 표준으로 다뤄야 합니다.

순위 2025 카테고리 2021 대비 변화
A01:2025 Broken Access Control 1위 유지. SSRF가 이 항목으로 흡수
A02:2025 Security Misconfiguration #5 → #2 (전 애플리케이션에서 발견)
A03:2025 Software Supply Chain Failures ⭐ NEW 기존 "Vulnerable Components"의 확장(빌드 시스템·CI/CD 포함)
A04:2025 Cryptographic Failures #2 → #4
A05:2025 Injection #3 → #5
A06:2025 Insecure Design 유지
A07:2025 Authentication Failures "Identification and Authentication Failures"에서 리네이밍
A08:2025 Software or Data Integrity Failures 유지
A09:2025 Security Logging and Alerting Failures "Monitoring" 빠지고 "Alerting" 강조
A10:2025 Mishandling of Exceptional Conditions ⭐ NEW 예외 처리·오류 흐름 미숙으로 발생하는 보안 사고

프론트엔드 관점에서 주목할 변화 3가지

  1. A02(Misconfiguration) 급상승 — CSP·CORS·보안 헤더 누락이 가장 흔한 침투 경로로 재확인되었습니다. 본 가이드 8번 섹션의 헤더 체크리스트를 반드시 자동화하세요.
  2. A03(Supply Chain) 신설 — npm 패키지·빌드 산출물·CI 토큰 전반의 무결성이 별도 카테고리로 분리되었습니다. 6번 섹션의 lockfile·provenance·npm audit signatures가 더 중요해졌습니다.
  3. A10(예외 처리) — 401/403/500 응답에 스택 트레이스·내부 경로가 노출되는 사례가 정식 항목이 되었습니다. 9.2의 프로덕션 에러 핸들러 패턴이 핵심입니다.

0.4 인증·공급망 공식 표준 해석

보안 문서에서 버전명과 표준 상태가 섞이면 잘못된 구현 결정을 만들 수 있습니다. 현재 공식 문서 기준 OAuth 2.1은 RFC가 아니라 개발 중인 통합 문서이므로, production 보안 기준은 RFC 9700 OAuth 2.0 Security BCP와 각 인증 제공자의 OIDC 문서로 판단합니다.

주제 production 기준 실행 증거 주의할 점
OAuth/OIDC Authorization Code + PKCE, exact redirect URI, state/nonce, issuer 검증, implicit/ROPC 회피 auth E2E, redirect URI test, token storage review “OAuth 2.1”이라고만 쓰지 말고 근거 RFC/프로파일을 명시
Token replay 방지 refresh token rotation 또는 sender-constrained token, audience/scope 최소화 rotation test, replay rejection log SPA client secret은 secret이 아님
JWT 검증 RFC 8725 기준 알고리즘 allowlist, iss/aud/exp/kid 검증 negative token test alg: none, key confusion, 과도한 만료 시간 금지
Supply chain SLSA provenance, SBOM, lockfile, CI 최소 권한, artifact 검증 SBOM/provenance/checksum artifact “audit 통과”만으로 무결성 증명이 되지 않음

1. XSS (Cross-Site Scripting) 방어

왜 중요한가

사용자의 입력값이 스크립트로 실행되는 것을 방지합니다. XSS는 가장 빈번하게 발생하는 웹 보안 취약점이며, Reflected / Stored / DOM-based 세 가지 유형이 존재합니다.

일상 비유: 식당 후기 게시판에 "음식 맛이 별로" 대신 "음식 맛이 별로 "를 적어 두면, 다른 손님이 게시판을 볼 때 그 스크립트가 실행됩니다. XSS는 글 속에 숨긴 명령이 다른 사람의 브라우저에서 실행되는 일입니다.

XSS 방어 다층 방어 흐름

flowchart TD
  A[사용자 입력] --> B{입력 경로}
  B -- 폼/URL/외부 API --> C[입력 검증 + Zod 스키마]
  C --> D[저장]
  D --> E[출력 시점]
  E --> F{출력 방식}
  F -- JSX 텍스트 --> G[React 자동 escape - 안전]
  F -- dangerouslySetInnerHTML --> H[DOMPurify sanitize]
  F -- href/src 속성 --> I[프로토콜 검증<br/>http/https/mailto만]
  F -- 스크립트 주입 시도 --> J[CSP: script-src nonce/hash]
  H --> K[브라우저 렌더]
  I --> K
  J --> K
  K --> L[Trusted Types: 잘못된 sink 호출 차단]
  L -. 위반 .-> M[CSP report-uri로 위반 보고]

1.1 React의 기본 XSS 방어와 한계

React는 JSX에서 렌더링되는 값을 자동으로 이스케이프하여 기본적인 XSS를 방어합니다. 하지만 안전하지 않은 경우가 존재합니다.

// ✅ 안전 — React가 자동으로 이스케이프 처리
const userInput = '<script>alert("xss")</script>'
return <div>{userInput}</div> // 문자열 그대로 출력됨

// ❌ 위험 — dangerouslySetInnerHTML은 이스케이프를 우회
return <div dangerouslySetInnerHTML={{ __html: userInput }} />

// ❌ 위험 — href에 javascript: 프로토콜 주입 가능
const maliciousUrl = 'javascript:alert("xss")'
return <a href={maliciousUrl}>Click</a>

// ✅ 안전한 URL 검증 함수
function isSafeUrl(url: string): boolean {
  try {
    const parsed = new URL(url)
    return ['http:', 'https:', 'mailto:'].includes(parsed.protocol)
  } catch {
    return false
  }
}

// ❌ 위험 — eval, Function 생성자, innerHTML 직접 조작
eval(userInput) // 절대 사용 금지
new Function(userInput)() // 절대 사용 금지
document.getElementById('target')!.innerHTML = userInput // React 외부 DOM 조작 금지

1.2 CSP (Content Security Policy) 설정

가장 강력한 방어 수단입니다. 브라우저에게 "이 도메인의 스크립트만 실행하라"고 지시합니다.

일상 비유: 공연장 입구에서 발급한 정확한 도장(nonce)이 찍힌 표만 입장을 허용하는 것과 같습니다. 위조 표(주입된 스크립트)는 도장이 없어 거부됩니다.

CSP 단계별 적용 로드맵

flowchart LR
  A[Phase 0<br/>CSP 미적용] --> B[Phase 1<br/>Content-Security-Policy-Report-Only]
  B --> C{위반 보고서<br/>분석 1~2주}
  C -- 합법 트래픽 위반 --> D[정책 보완<br/>도메인 화이트리스트]
  D --> B
  C -- 위반 0건 안정 --> E[Phase 2<br/>Enforcement 모드]
  E --> F[Phase 3<br/>nonce 기반 'strict-dynamic']
  F --> G[Phase 4<br/>require-trusted-types-for 'script']
  G --> H[Phase 5<br/>frame-ancestors / form-action 등 강화]
  C -. report-uri .-> R[보안 모니터링<br/>위반 패턴 알림]

CSP 디렉티브 의존 관계

flowchart TD
  D[default-src<br/>모든 리소스 기본값] --> S[script-src]
  D --> ST[style-src]
  D --> I[img-src]
  D --> C[connect-src]
  D --> F[font-src]
  D --> FR[frame-src]
  S -.-> N1["'nonce-...'<br/>per-request 토큰"]
  S -.-> H1["'strict-dynamic'<br/>nonce된 스크립트가 로드한 것 신뢰"]
  S -.-> WL1[도메인 화이트리스트]
  FA[frame-ancestors<br/>clickjacking 방지] -. 독립 .-> X1
  FORM[form-action<br/>제출 대상 제한] -. 독립 .-> X1
  RTT[require-trusted-types-for] -. 추가 강화 .-> S
  R[report-uri / report-to] -. 위반 수집 .-> X1[브라우저 적용]

주요 CSP 디렉티브 설명

디렉티브 설명 예시
default-src 모든 리소스의 기본 정책 (다른 디렉티브가 없을 때 폴백) 'self'
script-src JavaScript 소스 제한 'self' 'nonce-abc123'
style-src CSS 소스 제한 'self' 'unsafe-inline'
img-src 이미지 소스 제한 'self' data: https:
connect-src XHR, Fetch, WebSocket 연결 대상 제한 'self' https://api.example.com
font-src 웹 폰트 소스 제한 'self' https://font-cdn.example.com
frame-src iframe 소스 제한 'none'
object-src <object>, <embed> 제한 (Flash 등) 'none'
base-uri <base> 태그의 URL 제한 'self'
form-action 폼 제출 대상 제한 'self'
frame-ancestors 이 페이지를 iframe으로 삽입할 수 있는 부모 제한 'none'
upgrade-insecure-requests HTTP 요청을 HTTPS로 자동 업그레이드 (값 없음)

Meta 태그 방식 (간단한 프로젝트)

<!-- index.html 예시 -->
<meta
  http-equiv="Content-Security-Policy"
  content="
  default-src 'self';
  script-src 'self' https://trusted-api.com;
  style-src 'self' 'unsafe-inline' https://style-cdn.example.com;
  img-src 'self' data: https://images.unsplash.com;
  connect-src 'self' https://api.myserver.com;
  font-src 'self' https://font-cdn.example.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;
"
/>

Nonce 기반 CSP (Next.js)

'unsafe-inline'을 사용하지 않고, 요청마다 고유한 nonce를 생성하여 인라인 스크립트를 허용하는 방식입니다.

// middleware.ts — Next.js 미들웨어에서 nonce 생성
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

export function middleware(request: NextRequest) {
  // 요청마다 고유한 nonce 생성
  const nonce = crypto.randomBytes(16).toString('base64')

  // CSP 헤더 구성 — nonce 기반으로 인라인 스크립트 허용
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}' https://style-cdn.example.com`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.myserver.com`,
    `font-src 'self' https://font-cdn.example.com`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ')

  const response = NextResponse.next()

  // 응답 헤더에 CSP 설정
  response.headers.set('Content-Security-Policy', cspHeader)
  // nonce를 하위 컴포넌트에서 사용할 수 있도록 헤더로 전달
  response.headers.set('x-nonce', nonce)

  return response
}
// app/layout.tsx — nonce를 Script 태그에 전달
import { headers } from 'next/headers'
import Script from 'next/script'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const headersList = await headers()
  const nonce = headersList.get('x-nonce') ?? ''

  return (
    <html lang="ko">
      <body>
        {children}
        {/* nonce가 포함된 인라인 스크립트만 실행 허용 */}
        <Script nonce={nonce} strategy="afterInteractive">
          {`console.log("이 스크립트는 nonce로 인증되어 실행됩니다.");`}
        </Script>
      </body>
    </html>
  )
}

1.3 Trusted Types — DOM XSS의 근본 차단 (CSP Level 3)

Trusted TypesinnerHTML, document.write, evalDOM XSS 싱크(sink) 에 일반 문자열을 넣는 것을 브라우저 차원에서 금지합니다. 정책에서 생성된 안전한 타입(TrustedHTML, TrustedScript, TrustedScriptURL)만 허용되므로, 새니타이저를 거치지 않은 입력이 실행될 가능성이 원천 차단됩니다. 현재 공식 문서 기준 Chrome·Edge·Opera에 안정 적용되어 있고, Safari·Firefox는 플래그/실험 단계로 지원합니다.

일상 비유: 약국에서 처방전을 통과한 약(TrustedHTML)만 카운터 너머로 받을 수 있고, 누가 가져온 일반 봉투(문자열)는 받지 않는 규칙과 같습니다.

Trusted Types 처리 단계

sequenceDiagram
  participant App as 애플리케이션 코드
  participant Policy as trustedTypes.createPolicy('app-policy')
  participant Sanitizer as DOMPurify / allowlist
  participant Sink as innerHTML / script src
  participant Browser as 브라우저

  App->>Policy: createHTML(userInput)
  Policy->>Sanitizer: sanitize(userInput)
  Sanitizer-->>Policy: cleanHTML
  Policy-->>App: TrustedHTML 인스턴스
  App->>Sink: element.innerHTML = TrustedHTML
  Sink->>Browser: 안전한 HTML 삽입
  alt 잘못된 호출
    App->>Sink: element.innerHTML = "<script>...</script>" (raw string)
    Sink->>Browser: 거부 + TypeError
    Browser-->>App: CSP 위반 보고
  end
# 강제 모드 — 정책에서 생성되지 않은 값은 모두 거부
Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types default app-policy;
// app/trusted-types.ts — 앱이 사용할 정책 정의 (반드시 첫 로드 전에 실행)
import DOMPurify from 'dompurify'

if (window.trustedTypes && trustedTypes.createPolicy) {
  trustedTypes.createPolicy('app-policy', {
    // 모든 HTML 주입은 DOMPurify를 통과해야만 TrustedHTML이 됨
    createHTML: (input: string) => DOMPurify.sanitize(input),
    // 스크립트 URL 화이트리스트
    createScriptURL: (url: string) => {
      const allowed = ['https://cdn.example.com/', 'https://analytics.example.com/']
      if (allowed.some((prefix) => url.startsWith(prefix))) return url
      throw new Error(`허용되지 않은 스크립트 URL: ${url}`)
    },
  })
}

단계적 도입 팁: 즉시 차단이 부담스럽다면 먼저 Content-Security-Policy-Report-Only로 위반 보고서만 수집하세요. 의존 라이브러리(예: 일부 차트/에디터)가 미준수일 수 있으므로, 충돌하는 라이브러리를 모두 확인한 후 강제 모드로 전환합니다.

1.4 데이터 새니타이징 (Sanitization)

dangerouslySetInnerHTML 사용은 원칙적으로 금지하며, 불가피한 경우 DOMPurify를 사용합니다.

기본 사용법

import DOMPurify from "dompurify";

const cleanHTML = DOMPurify.sanitize(userInputHTML);
return <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />;

상세 설정 옵션

import DOMPurify from 'dompurify'

// 허용할 태그와 속성을 명시적으로 제한
const cleanHTML = DOMPurify.sanitize(dirtyHTML, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3'],
  ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
  ALLOW_DATA_ATTR: false, // data-* 속성 차단
  ADD_ATTR: ['target'], // 추가 허용 속성
  FORBID_TAGS: ['script', 'style', 'iframe', 'form', 'input'], // 명시적 차단
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], // 이벤트 핸들러 차단
})

// 커스텀 훅으로 재사용
function useSanitizedHTML(dirty: string) {
  return useMemo(
    () =>
      DOMPurify.sanitize(dirty, {
        ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
        ALLOWED_ATTR: ['href', 'target', 'rel'],
        // 모든 href에 자동으로 rel="noopener noreferrer" 추가
        ADD_ATTR: ['rel'],
      }),
    [dirty],
  )
}

// DOMPurify 훅을 활용한 링크 보안 강화
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  // 모든 외부 링크에 noopener noreferrer 추가
  if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
    node.setAttribute('rel', 'noopener noreferrer')
  }
  // javascript: 프로토콜 제거
  if (node.hasAttribute('href')) {
    const href = node.getAttribute('href') ?? ''
    if (href.startsWith('javascript:')) {
      node.removeAttribute('href')
    }
  }
})

서버 사이드용 (Node.js 환경)

// 서버 사이드에서는 isomorphic-dompurify 사용
import DOMPurify from 'isomorphic-dompurify'

// 사용법은 클라이언트와 동일
const clean = DOMPurify.sanitize(dirtyInput)

2. 안전한 토큰 관리 (Authentication)

왜 중요한가

JWT나 세션 토큰을 localStorage에 저장하는 것은 XSS 공격 시 토큰이 탈취될 수 있어 지양해야 합니다. 토큰 저장 위치, 만료/회전, 재인증 UX를 함께 설계하지 않으면 한쪽 약점이 시스템 전체를 뚫리게 만듭니다.

일상 비유: 호텔 카드키를 가방에 그대로 두면(localStorage) 가방을 잃어버릴 때 키도 함께 사라집니다. 객실 비번을 직원이 별도 채널로 잠깐만 보여주듯이(HttpOnly + Silent Refresh), 토큰도 노출 표면을 최소화하고 짧게 가져갑니다.

인증 방식 선택 결정 트리

이 그림은 새 기능을 설계할 때 어떤 인증/세션 전략을 적용할지 단계별로 결정하는 흐름을 보여줍니다.

flowchart TD
  Start[새 인증/세션 설계] --> Q1{사용자 유형}
  Q1 -- 신규 가입 + 보안 우선 --> Pass[Passkeys WebAuthn<br/>피싱 내성 + 비밀 미보관]
  Q1 -- 외부 IdP 연동 --> Q2{쿠키 공유 도메인}
  Q2 -- 동일 도메인 --> Session[HttpOnly Session Cookie<br/>+ Lax SameSite]
  Q2 -- 크로스 도메인/SPA --> OAuth[OAuth 2.0 Authorization Code<br/>+ PKCE + state/nonce]
  OAuth --> Q3{토큰 저장 위치}
  Q3 -- access token --> Mem[메모리 변수<br/>새로고침 시 silent refresh]
  Q3 -- refresh token --> HC[HttpOnly + Secure + SameSite Cookie]
  HC --> Rot[Refresh Token Rotation<br/>재사용 감지 시 전체 폐기]
  Q1 -- 기존 비밀번호 --> Q4{2FA 필요}
  Q4 -- 예 --> TwoFA[TOTP/WebAuthn 보조 인증]
  Q4 -- 아니오 --> Warn[권장: 단계적 Passkeys 전환 + Rate limit]
  Pass --> Backup[백업 인증 수단 등록 강제]
  TwoFA --> Backup

OAuth 2.0 + PKCE 인증 흐름 (RFC 9700 기준)

sequenceDiagram
  autonumber
  participant U as 사용자
  participant FE as 클라이언트(SPA/Next.js)
  participant AS as Authorization Server
  participant RS as Resource Server (API)

  U->>FE: 로그인 시도
  FE->>FE: code_verifier 생성<br/>code_challenge = SHA256(code_verifier)
  FE->>AS: /authorize?response_type=code&PKCE&state&nonce<br/>redirect_uri=exact
  AS->>U: 로그인/동의 화면
  U->>AS: 자격증명 제출
  AS-->>FE: 302 -> redirect_uri?code&state
  FE->>FE: state 검증 (CSRF 방지)
  FE->>AS: /token (code + code_verifier)
  AS-->>FE: access_token (short-lived)<br/>+ refresh_token (HttpOnly cookie)
  FE->>FE: access_token은 메모리에만 저장
  loop API 호출
    FE->>RS: Authorization: Bearer access_token
    RS-->>FE: 응답 또는 401
    opt 401 만료
      FE->>AS: /refresh (HttpOnly cookie 자동 포함)
      AS-->>FE: 새 access_token + 회전된 refresh_token
    end
  end
  Note over FE,AS: refresh_token rotation - 재사용 감지 시 전체 폐기

토큰 저장 위치 비교

flowchart LR
  subgraph Bad[권장하지 않음]
    A1[localStorage<br/>access + refresh]
    A2[일반 cookie<br/>HttpOnly 없음]
  end
  subgraph Good[권장 패턴]
    B1[메모리 변수<br/>access token]
    B2[HttpOnly + Secure + SameSite Cookie<br/>refresh token]
  end
  X[XSS 발생] -. 토큰 탈취 가능 .-> A1
  X -. 일부 탈취 가능 .-> A2
  X -. JS 접근 불가 .-> B2
  X -. 메모리만 영향, 새로고침 시 초기화 .-> B1

현재 표준 권고:

  • OAuth/OIDC는 RFC 9700 OAuth 2.0 Security BCP를 production 기준으로 삼습니다. Implicit/Resource Owner Password Credentials 그랜트는 회피하고, Authorization Code + PKCE, exact redirect URI, state/nonce, issuer 검증을 기본값으로 둡니다. Refresh Token은 sender-constrained 또는 rotation이 권장됩니다.
  • JWT는 RFC 8725(JWT BCP) 와 그 개정안(draft-ietf-oauth-rfc8725bis)에 따라 alg: none 금지, 알고리즘 화이트리스트 검증, kid/iss/aud 명시적 검증, 짧은 만료 시간을 적용합니다.
  • 신규 인증은 Passkeys(WebAuthn)를 우선 후보로 검토합니다. 단, 계정 복구, 기기 변경, 조직 정책, 접근성, 고객 지원 비용을 위협 모델과 UX 테스트로 함께 검증합니다.

2.1 HttpOnly + Secure 쿠키 전략

토큰은 JavaScript에서 접근할 수 없는 HttpOnly 쿠키에 저장하여 XSS를 통한 탈취를 막습니다.

옵션 설명 권장 값
HttpOnly JS에서 document.cookie로 접근 불가 true
Secure HTTPS 연결에서만 전송 true
SameSite 외부 사이트 요청 시 쿠키 전송 제어 Lax 또는 Strict
Path 쿠키가 전송되는 경로 제한 / 또는 /api
Max-Age 쿠키 만료 시간(초) Refresh Token용: 604800 (7일)
// 서버 측 — 쿠키 설정 예시 (Express)
import { Response } from 'express'

function setAuthCookies(res: Response, refreshToken: string) {
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true, // JS 접근 차단
    secure: true, // HTTPS만 허용
    sameSite: 'lax', // CSRF 기본 방어
    path: '/api/auth', // 인증 경로에서만 전송
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7일 (밀리초)
  })
}

2.2 In-memory 토큰 + Silent Refresh 패턴

Access Token은 메모리에 변수로만 유지하고, Refresh Token을 HttpOnly 쿠키에 담아 만료 시마다 자동으로 재발급받는 패턴을 권장합니다.

// auth/tokenManager.ts — 인메모리 토큰 관리자
let accessToken: string | null = null

export const tokenManager = {
  // Access Token 가져오기
  getToken(): string | null {
    return accessToken
  },

  // Access Token 설정 (메모리에만 저장, 어디에도 persist 하지 않음)
  setToken(token: string): void {
    accessToken = token
  },

  // 토큰 삭제 (로그아웃 시)
  clearToken(): void {
    accessToken = null
  },
}
// auth/silentRefresh.ts — Silent Refresh 로직
import axios from 'axios'
import { tokenManager } from './tokenManager'

// Refresh 요청 중복 방지를 위한 Promise 캐싱
let refreshPromise: Promise<string> | null = null

export async function silentRefresh(): Promise<string> {
  // 이미 갱신 요청이 진행 중이면 해당 Promise를 재사용 (경쟁 상태 방지)
  if (refreshPromise) {
    return refreshPromise
  }

  refreshPromise = axios
    .post<{ accessToken: string }>(
      '/api/auth/refresh',
      {},
      { withCredentials: true }, // HttpOnly 쿠키 자동 포함
    )
    .then((res) => {
      const newToken = res.data.accessToken
      tokenManager.setToken(newToken)
      return newToken
    })
    .finally(() => {
      refreshPromise = null // 완료 후 캐시 해제
    })

  return refreshPromise
}
// auth/axiosInstance.ts — Axios 인터셉터로 자동 토큰 관리
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenManager } from './tokenManager'
import { silentRefresh } from './silentRefresh'

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true, // 쿠키 자동 포함
})

// 요청 인터셉터 — 모든 요청에 Access Token 자동 첨부
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = tokenManager.getToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 응답 인터셉터 — 401 에러 시 Silent Refresh 후 재시도
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean
    }

    // 401 에러이고, 아직 재시도하지 않은 요청인 경우
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true // 무한 루프 방지 플래그

      try {
        const newToken = await silentRefresh()
        originalRequest.headers.Authorization = `Bearer ${newToken}`
        return api(originalRequest) // 원래 요청 재시도
      } catch (refreshError) {
        // Refresh Token도 만료된 경우 — 로그아웃 처리
        tokenManager.clearToken()
        window.location.href = '/login'
        return Promise.reject(refreshError)
      }
    }

    return Promise.reject(error)
  },
)

export default api

2.3 토큰 로테이션 (Refresh Token Rotation)

Refresh Token을 한 번 사용하면 폐기하고 새로운 Refresh Token을 발급하는 전략입니다. 토큰이 탈취되더라도 한 번만 사용 가능하므로 피해를 최소화합니다.

Refresh Token 회전 상태도

stateDiagram-v2
  [*] --> Issued: 로그인 성공
  Issued --> Used: /refresh 호출
  Used --> Revoked: 즉시 폐기 표시
  Revoked --> NewIssued: 새 refresh 발급
  NewIssued --> Used: 다음 회전
  Revoked --> Compromised: 이미 폐기된 토큰<br/>재사용 시도
  Compromised --> AllRevoked: 사용자 전체 토큰 폐기<br/>강제 로그아웃 + 알림
  AllRevoked --> [*]
  NewIssued --> Expired: maxAge 초과
  Expired --> [*]
// 서버 측 — Refresh Token Rotation 구현 예시
async function handleRefresh(req: Request, res: Response) {
  const oldRefreshToken = req.cookies.refresh_token

  // 1. DB에서 Refresh Token 유효성 확인
  const tokenRecord = await db.refreshToken.findUnique({
    where: { token: oldRefreshToken },
  })

  if (!tokenRecord || tokenRecord.revoked) {
    // 이미 사용된 토큰이 다시 사용됨 → 토큰 탈취 의심
    // 해당 사용자의 모든 Refresh Token 폐기 (보안 조치)
    await db.refreshToken.updateMany({
      where: { userId: tokenRecord?.userId },
      data: { revoked: true },
    })
    res.status(401).json({ error: '의심스러운 토큰 재사용 감지' })
    return
  }

  // 2. 기존 Refresh Token 폐기
  await db.refreshToken.update({
    where: { id: tokenRecord.id },
    data: { revoked: true },
  })

  // 3. 새로운 토큰 쌍 발급
  const newAccessToken = generateAccessToken(tokenRecord.userId)
  const newRefreshToken = generateRefreshToken()

  // 4. 새 Refresh Token DB 저장
  await db.refreshToken.create({
    data: {
      token: newRefreshToken,
      userId: tokenRecord.userId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  })

  // 5. 새 Refresh Token을 HttpOnly 쿠키로 설정
  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/api/auth',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  })

  res.json({ accessToken: newAccessToken })
}

2.4 Passkeys / WebAuthn — 비밀번호 없는 인증 (현재 권장)

Passkeys는 WebAuthn 표준 위에 구축된 공개키 기반 자격증명으로, 피싱 내성·재사용 불가·서버에 비밀 미보관의 세 가지 보안 특성을 만족합니다. 현재 공식 문서 기준 W3C WebAuthn Level 3 워킹 드래프트가 진행 중이며, 주요 플랫폼(iOS/Android/Windows/macOS)이 클라우드 동기화 패스키를 기본 지원합니다.

// 등록(Registration): 서버가 challenge를 만들어 클라이언트에 전달
export async function registerPasskey(username: string) {
  // 1) 서버에서 등록 옵션 요청
  const options = await api.get('/auth/passkey/register-options', { params: { username } })

  // 2) 브라우저가 사용자 인증기(생체, 보안키)로 키쌍 생성
  const credential = await navigator.credentials.create({
    publicKey: {
      ...options,
      // 서버 challenge를 ArrayBuffer로 변환 (base64url 디코드)
      challenge: base64UrlToBuffer(options.challenge),
      user: {
        ...options.user,
        id: base64UrlToBuffer(options.user.id),
      },
    },
  })

  // 3) 공개키와 attestation을 서버로 전송 → DB 저장
  await api.post('/auth/passkey/register', serializeCredential(credential))
}

// 인증(Authentication): 서버 challenge에 사용자 비밀키로 서명
export async function loginWithPasskey() {
  const options = await api.get('/auth/passkey/login-options')

  const assertion = await navigator.credentials.get({
    publicKey: {
      ...options,
      challenge: base64UrlToBuffer(options.challenge),
    },
    // 자동 입력(autofill)을 위해 conditional mediation 사용
    mediation: 'conditional',
  })

  // 서버는 저장된 공개키로 서명 검증 후 세션 발급
  return api.post('/auth/passkey/verify', serializeAssertion(assertion))
}

운영 체크포인트:

  • 백업 인증 수단 필수: 디바이스 분실 대비 두 번째 패스키 등록을 가입 플로우에 포함하세요. 매직 링크/SMS OTP는 폴백으로만 사용.
  • 사용자 ID(user.id)는 안정적인 식별자여야 합니다. 이메일을 직접 사용하면 변경 시 패스키가 무효화됩니다. 내부 UUID 사용 권장.
  • RP ID 는 eTLD+1(예: example.com)을 사용해 서브도메인 간 공유 가능하게 설계합니다.
  • 서버 라이브러리: Node는 @simplewebauthn/server, Python은 webauthn, Go는 go-webauthn 같은 대표 후보를 검토합니다.

3. CSRF (Cross-Site Request Forgery) 방어

왜 중요한가

브라우저가 쿠키를 자동으로 전송하는 특성을 악용한 공격을 막습니다. 사용자가 의도하지 않은 요청이 인증된 상태로 서버에 도달하는 것을 방지해야 합니다.

일상 비유: 누군가 내 휴대폰을 잠금 해제된 상태에서 손에 쥐여 주고 "결제 버튼 누르세요"라고 시키는 것과 같습니다. 카드(쿠키)는 이미 내 폰(브라우저) 안에 있으므로, 시키는 대로 누르면 자동 결제됩니다. CSRF 토큰은 "이 결제가 정말 내 의지인가"를 확인하는 추가 도장입니다.

CSRF Synchronizer Token 흐름

sequenceDiagram
  autonumber
  participant U as 사용자(피해자)
  participant App as 정상 사이트(app.com)
  participant Att as 공격자 페이지

  U->>App: 로그인 (세션 쿠키 발급)
  App->>U: 페이지 렌더 + CSRF 토큰 (메타 또는 쿠키)
  U->>App: 정상 POST + X-CSRF-Token 헤더
  App-->>U: 토큰 일치 -> 처리

  Note over U,Att: 공격 시도
  U->>Att: 공격자가 만든 페이지 방문
  Att->>App: <form action=/transfer> 자동 제출
  App-->>Att: 쿠키는 자동 포함되지만<br/>X-CSRF-Token 없음 -> 403 거부

3.1 SameSite 쿠키 설정

Chrome 80(2020년 2월)부터 모든 Chromium 기반 브라우저는 SameSite 속성이 없는 쿠키를 Lax로 기본 해석합니다. SameSite를 생략해도 1차 컨텍스트에서만 전송되지만, 보안 표준에서는 명시적으로 선언해 의도를 코드로 드러냅니다.

동작 적합한 경우
Strict 외부 사이트에서 오는 모든 요청에 쿠키 미전송 은행, 결제 등 높은 보안 필요
Lax GET 네비게이션은 허용, POST 등은 차단 일반적인 웹 서비스, 현대 브라우저 기본 정책과 잘 맞음
None 모든 요청에 쿠키 전송 (Secure 필수, HTTPS 한정) 서드파티 임베드·결제 위젯 등 명확한 사유

최신 운영 주의사항: SameSite=None 쿠키는 Secure 필수 + HTTPS 한정으로만 동작합니다. 또한 Chrome의 서드파티 쿠키 단계적 제한 정책에 따라, SaaS 임베드·외부 결제 위젯은 Storage Access API 또는 Cross-Origin-Opener-Policy: restrict-properties 같은 대안 통합을 미리 준비하세요.

3.2 CSRF 토큰 패턴 (Synchronizer Token)

서버가 생성한 고유 토큰을 클라이언트가 요청에 포함하여 전송하는 전통적인 방어법입니다.

// 서버 측 — CSRF 토큰 생성 및 검증 (Express 미들웨어)
import crypto from 'crypto'
import { Request, Response, NextFunction } from 'express'

// CSRF 토큰 생성 엔드포인트
app.get('/api/csrf-token', (req: Request, res: Response) => {
  // 세션마다 고유한 CSRF 토큰 생성
  const csrfToken = crypto.randomBytes(32).toString('hex')

  // 세션에 저장 (서버 측 검증용)
  req.session.csrfToken = csrfToken

  // 클라이언트에 토큰 전달
  res.json({ csrfToken })
})

// CSRF 검증 미들웨어
function verifyCsrf(req: Request, res: Response, next: NextFunction) {
  // GET, HEAD, OPTIONS는 검증 생략 (안전한 메서드)
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next()
  }

  const clientToken = req.headers['x-csrf-token'] as string
  const serverToken = req.session.csrfToken

  if (!clientToken || clientToken !== serverToken) {
    res.status(403).json({ error: 'CSRF 토큰이 유효하지 않습니다.' })
    return
  }

  next()
}

// 모든 API 라우트에 적용
app.use('/api', verifyCsrf)
// 클라이언트 측 — CSRF 토큰을 Axios에 자동 포함
import axios from 'axios'

let csrfToken: string | null = null

// 앱 초기화 시 CSRF 토큰 요청
export async function initCsrfToken(): Promise<void> {
  const res = await axios.get<{ csrfToken: string }>('/api/csrf-token', {
    withCredentials: true,
  })
  csrfToken = res.data.csrfToken
}

// Axios 인터셉터로 모든 상태 변경 요청에 CSRF 토큰 포함
axios.interceptors.request.use((config) => {
  if (csrfToken && config.method !== 'get') {
    config.headers['X-CSRF-Token'] = csrfToken
  }
  return config
})

서버 세션 없이 CSRF를 방어하는 경량 패턴입니다. 쿠키와 헤더에 동일한 값을 담아 비교합니다.

// 서버 측 — Double Submit Cookie 설정
import crypto from 'crypto'

app.get('/api/auth/login', (req, res) => {
  // ... 인증 로직 ...

  // CSRF 방어용 토큰을 일반 쿠키(JS 접근 가능)로 설정
  const csrfToken = crypto.randomBytes(32).toString('hex')
  res.cookie('csrf_token', csrfToken, {
    httpOnly: false, // JS에서 읽을 수 있어야 함
    secure: true,
    sameSite: 'lax',
    path: '/',
  })

  res.json({ success: true })
})

// 검증 미들웨어
function verifyDoubleSubmit(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next()
  }

  // 쿠키의 값과 헤더의 값이 일치하는지 확인
  const cookieToken = req.cookies.csrf_token
  const headerToken = req.headers['x-csrf-token']

  if (!cookieToken || cookieToken !== headerToken) {
    res.status(403).json({ error: 'CSRF 검증 실패' })
    return
  }

  next()
}
// 클라이언트 측 — 쿠키에서 읽어 헤더로 전송
function getCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
  return match ? match[2] : null
}

// Axios 인터셉터
axios.interceptors.request.use((config) => {
  if (config.method !== 'get') {
    const csrfToken = getCookie('csrf_token')
    if (csrfToken) {
      config.headers['X-CSRF-Token'] = csrfToken
    }
  }
  return config
})

3.4 Custom Header 방어

API 요청 시 커스텀 헤더를 포함하면 브라우저의 CORS preflight에 의해 다른 사이트에서의 요청이 차단됩니다.

일상 비유: 행사 입장 시 손목띠(SameSite)만 보지 않고, 도장(CSRF 토큰), 추가 신분증(Custom Header), 본인 확인서(Re-auth)까지 다층으로 확인하는 방식입니다.

CSRF 방어 다층 결정 트리

이 그림은 신규 API/액션을 추가할 때 어떤 CSRF 방어 조합을 적용할지 결정하는 흐름을 보여줍니다.

flowchart TD
  Start[신규 mutation 엔드포인트] --> Q1{인증 방식}
  Q1 -- 쿠키 기반 세션 --> Q2{메서드}
  Q2 -- GET/HEAD --> Safe[CSRF 위험 낮음<br/>SameSite=Lax로 충분]
  Q2 -- POST/PUT/DELETE --> Q3{교차 출처 임베드 가능}
  Q3 -- 없음 --> Lax[SameSite=Strict<br/>+ Custom Header 검사]
  Q3 -- 있음 결제/위젯 --> Token[Synchronizer Token<br/>또는 Double Submit Cookie]
  Token --> Q4{민감도}
  Q4 -- 최고 자금이체/관리 --> Reauth[Token + Step-up Re-auth<br/>WebAuthn 재인증]
  Q4 -- 일반 --> Token2[Token + Custom Header 조합]
  Q1 -- Bearer 토큰 SPA --> Bearer[CSRF 본질적 영향 낮음<br/>단 Storage Access API 대비]
  Q1 -- Passkey 인증 --> Pass[리플레이 방지<br/>CSRF 위험 자체 낮음]
// 모든 API 요청에 커스텀 헤더 추가
const api = axios.create({
  baseURL: '/api',
  headers: {
    'X-Requested-With': 'XMLHttpRequest', // 커스텀 헤더 — preflight 트리거
  },
})

// 서버 측 — 커스텀 헤더 검증
function verifyCustomHeader(req: Request, res: Response, next: NextFunction) {
  if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
    res.status(403).json({ error: '잘못된 요청입니다.' })
    return
  }
  next()
}

4. 보안 점검 자동화 (CI/CD 연계)

보안은 사람이 매번 체크하기 어렵습니다. CI/CD 파이프라인에 자동화 도구를 통합하세요.

4.1 npm audit — CI 파이프라인 통합

# .ci/workflows/security-audit.yml
name: Security Audit

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  # 매주 월요일 오전 9시(KST) 정기 스캔
  schedule:
    - cron: '0 0 * * 1'

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Node.js 설정
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: 의존성 설치
        run: npm ci

      - name: npm audit 실행 (high/critical만 실패 처리)
        run: npm audit --audit-level=high

      - name: 라이선스 검사
        run: npx license-checker --failOn "GPL-3.0;AGPL-3.0"

      - name: 번들 크기 확인 (보안 외 품질 게이트)
        run: npx size-limit

4.2 Snyk 연동

# .ci/workflows/snyk.yml
name: Snyk Security Scan

on:
  pull_request:
    branches: [main]

jobs:
  snyk:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Snyk 의존성 취약점 스캔
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

      - name: Snyk 코드 취약점 스캔
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          command: code test
// .snyk — Snyk 정책 파일
{
  "version": "v1.25.0",
  "ignore": {},
  "patch": {},
  "language-settings": {
    "javascript": {
      "severity-threshold": "high"
    }
  }
}

4.3 보안 헤더 자동 검증

// scripts/check-security-headers.ts — 배포 후 보안 헤더 검증 스크립트
const REQUIRED_HEADERS: Record<string, string | RegExp> = {
  'content-security-policy': /default-src/,
  'x-content-type-options': 'nosniff',
  'x-frame-options': /DENY|SAMEORIGIN/,
  'strict-transport-security': /max-age=\d+/,
  'referrer-policy': /strict-origin|no-referrer/,
  'permissions-policy': /.+/,
}

async function checkSecurityHeaders(url: string): Promise<void> {
  const response = await fetch(url, { method: 'HEAD' })
  const failures: string[] = []

  for (const [header, expected] of Object.entries(REQUIRED_HEADERS)) {
    const value = response.headers.get(header)

    if (!value) {
      failures.push(`❌ [누락] ${header}`)
      continue
    }

    if (typeof expected === 'string' && value !== expected) {
      failures.push(`❌ [불일치] ${header}: "${value}" (예상: "${expected}")`)
    } else if (expected instanceof RegExp && !expected.test(value)) {
      failures.push(`❌ [불일치] ${header}: "${value}" (패턴: ${expected})`)
    } else {
      console.log(`✅ ${header}: ${value}`)
    }
  }

  if (failures.length > 0) {
    console.error('\n보안 헤더 검증 실패:')
    failures.forEach((f) => console.error(f))
    process.exit(1)
  }

  console.log('\n✅ 모든 보안 헤더가 올바르게 설정되어 있습니다.')
}

// 사용: npx ts-node scripts/check-security-headers.ts https://my-app.com
const targetUrl = process.argv[2]
if (!targetUrl) {
  console.error('사용법: npx ts-node scripts/check-security-headers.ts <URL>')
  process.exit(1)
}
checkSecurityHeaders(targetUrl)
# CI에서 배포 후 자동 검증
- name: 보안 헤더 검증
  run: npx ts-node scripts/check-security-headers.ts ${{ env.DEPLOY_URL }}

5. CORS 정책 이해와 설정

왜 중요한가

CORS(Cross-Origin Resource Sharing)는 다른 출처(origin)에서의 리소스 요청을 제어하는 브라우저 보안 메커니즘입니다. 잘못된 CORS는 자격증명 탈취, 데이터 노출로 이어지고, 너무 강한 CORS는 정상 서비스 통합을 막습니다.

일상 비유: 본사 출입증으로 협력사 빌딩에 들어가려면 사전에 협력사 보안실(서버)이 본사를 화이트리스트에 올려야 하고, 본인은 사전 등록(Preflight)을 거쳐야 합니다.

5.1 CORS 동작 원리

1. 브라우저가 다른 출처로 요청 전송
2. Simple Request가 아니면 → Preflight (OPTIONS) 요청 발생
3. 서버가 Access-Control-Allow-* 헤더로 허용 여부 응답
4. 허용된 경우에만 실제 요청 전송

CORS Preflight 흐름

sequenceDiagram
  participant B as 브라우저
  participant S as 서버

  Note over B,S: Simple Request 조건 미충족 시
  B->>S: OPTIONS /api/data<br/>Origin, Access-Control-Request-Method, Headers
  S-->>B: 200 OK<br/>Access-Control-Allow-Origin: https://app.com<br/>Allow-Methods, Allow-Headers, Allow-Credentials<br/>Max-Age: 86400
  alt 정책 일치
    B->>S: 실제 요청 (POST/PUT...)<br/>Authorization, X-CSRF-Token 등
    S-->>B: 정상 응답
  else 정책 불일치
    B->>B: 차단, 콘솔 CORS 에러
  end
  Note over B: Max-Age 동안 동일 prefligh 캐시 -> OPTIONS 생략

Simple Request 조건 (Preflight 생략):

  • 메서드: GET, HEAD, POST
  • 헤더: Accept, Content-Type(일부), Accept-Language, Content-Language
  • Content-Type: application/x-www-form-urlencoded, multipart/form-data, text/plain

5.2 서버 측 CORS 설정 (Express)

import cors from 'cors'

// ❌ 나쁜 예 — 모든 출처 허용 (개발 환경에서만)
app.use(cors({ origin: '*' }))

// ✅ 좋은 예 — 허용할 출처를 명시적으로 지정
const allowedOrigins = [
  'https://my-app.com',
  'https://admin.my-app.com',
  // 개발 환경
  ...(process.env.NODE_ENV === 'development'
    ? ['http://localhost:3000', 'http://localhost:3001']
    : []),
]

app.use(
  cors({
    origin: (origin, callback) => {
      // origin이 없는 경우 (같은 출처 또는 서버 간 요청)
      if (!origin || allowedOrigins.includes(origin)) {
        callback(null, true)
      } else {
        callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`))
      }
    },
    credentials: true, // 쿠키 포함 허용 (withCredentials)
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'X-Requested-With'],
    exposedHeaders: ['X-Total-Count'], // 클라이언트에서 접근 가능한 응답 헤더
    maxAge: 86400, // Preflight 캐시: 24시간 (OPTIONS 요청 최소화)
  }),
)

5.3 Next.js CORS 설정

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://my-app.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,PATCH' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type,Authorization,X-CSRF-Token' },
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Max-Age', value: '86400' },
        ],
      },
    ]
  },
}

export default nextConfig

5.4 클라이언트 측 주의사항

// credentials를 사용할 때 주의사항
const api = axios.create({
  baseURL: 'https://api.my-app.com',
  withCredentials: true, // 쿠키를 포함해야 할 때만 true
})

// ⚠️ withCredentials: true일 때 서버의 Access-Control-Allow-Origin은
//    "*"가 될 수 없음. 반드시 구체적인 출처를 명시해야 함.

6. 의존성 보안: Supply Chain Attack 방어 (OWASP A03:2025)

왜 중요한가

npm 패키지를 통한 공급망 공격(Supply Chain Attack)이 증가하고 있으며, OWASP Top 10:2025의 A03(Software Supply Chain Failures) 로 별도 카테고리화되었습니다. Lockfile 무결성, 패키지 출처 검증, 설치 스크립트 제어, 빌드 시스템·CI 토큰 보호가 핵심입니다.

일상 비유: 식자재 유통은 농장 → 도매 → 식당까지 단계마다 송장과 봉인이 있어야 위·변조가 즉시 드러납니다. 코드도 마찬가지로 source → registry → build artifact → 배포까지 단계마다 무결성 증거가 있어야 합니다.

공급망 검증 파이프라인

flowchart LR
  subgraph Source[소스]
    A1[git commit signed]
    A2[CODEOWNERS / branch protection]
  end
  subgraph Build[빌드]
    B1[npm ci<br/>lockfile 기반]
    B2[npm audit signatures]
    B3[npm audit --audit-level=high]
    B4[ignore-scripts / 화이트리스트]
  end
  subgraph Artifact[산출물]
    C1[SBOM 생성]
    C2[build provenance / attestation]
    C3[checksum]
  end
  subgraph Release[배포]
    D1[gh attestation verify]
    D2[registry signing key check]
    D3[Pin by digest, 환경별 promote]
  end
  A1 --> B1
  A2 --> B1
  B1 --> B2 --> B3 --> B4
  B4 --> C1 --> C2 --> C3
  C3 --> D1 --> D2 --> D3
  D3 -. 사고 시 .-> R[Rollback by checksum<br/>+ alert]

6.1 Lockfile 무결성 보장

# CI에서는 반드시 npm ci 사용 (lockfile 기반 정확한 설치)
npm ci          # ✅ package-lock.json과 정확히 일치하는 버전 설치
npm install     # ❌ CI에서 사용 금지 — lockfile이 변경될 수 있음
// .npmrc — 보안 강화 설정
{
  "// lockfile 무결성": "",
  "package-lock": true,
  "// 설치 스크립트 제어 (typosquatting 방어)": "",
  "ignore-scripts": true,
  "// 정확한 버전 저장": "",
  "save-exact": true
}
# .npmrc 파일 형식
package-lock=true
save-exact=true
ignore-scripts=true

주의: ignore-scripts=true를 설정하면 postinstall 스크립트가 실행되지 않습니다. 일부 패키지(esbuild, sharp 등)는 postinstall이 필요하므로, 해당 패키지는 별도로 허용합니다.

6.2 provenance와 attestation (출처 검증)

패키지 매니저의 서명 검증은 dependency 수신 단계의 증거이고, artifact attestation은 빌드 산출물이 어떤 source/workflow에서 만들어졌는지 증명하는 배포 단계의 증거입니다. production 승격 기준은 둘을 분리해서 봅니다.

# 패키지 서명/출처 확인
npm audit signatures

# 특정 패키지의 출처 상세 확인
npm view <패키지명> --json | jq '.dist.attestations'

# 빌드 산출물 attestation 검증 예시
gh attestation verify dist/app.tar.gz -R ORG/REPO
증거 막는 위험 보관 위치
lockfile diff 의도하지 않은 dependency drift PR
package signature/provenance registry 수신 단계 변조 CI report
SBOM 어떤 component가 artifact에 포함됐는지 추적 release artifact
build provenance/attestation source revision, workflow, builder 위변조 확인 release artifact 또는 registry
checksum 다운로드/업로드 중 artifact 변조 확인 release manifest

6.3 의존성 보안 CI 파이프라인

# .ci/workflows/supply-chain.yml
name: Supply Chain Security

on:
  pull_request:
    paths:
      - 'package.json'
      - 'package-lock.json'

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Node.js 설정
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: lockfile 변경 감지 경고
        run: |
          # package-lock.json의 변경이 의도적인지 확인
          echo "📦 lockfile이 변경되었습니다. 의존성 변경을 리뷰해주세요."
          git diff origin/main -- package-lock.json | head -100

      - name: 의존성 설치 (lockfile 기반)
        run: npm ci

      - name: npm audit 서명 검증
        run: npm audit signatures

      - name: npm audit 취약점 검사
        run: npm audit --audit-level=high

      - name: 새로 추가된 의존성 목록 출력
        run: |
          # 새로 추가된 패키지 확인
          git diff origin/main -- package.json | grep '^\+.*":'  | grep -v '^\+\+\+' || echo "새로운 의존성 없음"

6.4 안전한 의존성 관리 원칙

일상 비유: 새 식자재를 들이듯, 새 패키지도 출처(농장) → 유통(registry) → 사용 빈도(소진) → 보관(버전 고정)을 모두 확인합니다.

새 의존성 도입 결정 트리

이 그림은 PR에서 신규 npm 패키지가 추가될 때 머지 가능성 판단의 게이트를 보여줍니다.

flowchart TD
  Start[신규 의존성 추가 PR] --> Q1{대안: shared/lib<br/>자체 구현 가능}
  Q1 -- 가능 --> Self[자체 구현 권장<br/>10~20줄 이내]
  Q1 -- 불가 --> Q2{npm audit signatures 통과}
  Q2 -- 실패 --> Reject[즉시 반려<br/>signing key 검증 불가]
  Q2 -- 통과 --> Q3{최근 6개월 활성 + GitHub stars}
  Q3 -- 아니오 --> Slow[느린 검토 -- ADR 작성]
  Q3 -- 예 --> Q4{보안 advisory 이력}
  Q4 -- High/Critical 있음 --> Q5{대안 패키지 존재}
  Q5 -- 예 --> Switch[대안 도입]
  Q5 -- 아니오 --> Risk[ADR + 책임자 지정 + 모니터링]
  Q4 -- 깨끗 --> Q6{ignore-scripts 호환}
  Q6 -- 예 --> Lock[정확한 버전 고정 + SBOM 추가]
  Q6 -- 아니오 --> Allow[postinstall 화이트리스트<br/>+ 빌드 격리]
  Lock --> Done[머지 가능]
  Allow --> Done
// ❌ 위험 — 범위 지정자 사용
"dependencies": {
  "lodash": "^4.17.0",     // ^ → 4.x.x 범위의 최신 버전 설치 가능
  "moment": "~2.29.0"      // ~ → 2.29.x 범위의 최신 버전 설치 가능
}

// ✅ 안전 — 정확한 버전 고정 (save-exact 옵션 활용)
"dependencies": {
  "lodash": "4.17.21",     // 정확한 버전만 설치
  "dayjs": "1.11.10"
}

7. 환경 변수 및 시크릿 관리

프론트엔드에서 환경 변수를 잘못 사용하면 민감한 정보가 클라이언트에 노출될 수 있습니다.

일상 비유: 가방 안에 "공개해도 되는 명함"과 "절대 보여주면 안 되는 인감"을 같이 들고 다니면 사고가 납니다. 환경 변수도 두 칸으로 분리하고, 어느 칸이 어디로 가는지 분류해야 합니다.

환경 변수 분류 결정 트리

이 그림은 새 환경 변수를 추가할 때 NEXTPUBLIC 접두사 사용 여부를 단계적으로 판단하는 흐름을 보여줍니다.

flowchart TD
  Start[새 환경 변수 추가] --> Q1{값의 민감도}
  Q1 -- 비밀번호/토큰/시크릿 --> Server[서버 전용<br/>NEXT_PUBLIC_ 금지<br/>접두사 없이 보관]
  Q1 -- 공개 가능 --> Q2{사용 위치}
  Q2 -- 클라이언트 코드에서 참조 --> Public[NEXT_PUBLIC_ 접두사<br/>예 NEXT_PUBLIC_API_URL]
  Q2 -- 서버 라우트/Action에서만 --> Server
  Server --> Q3{시크릿 회전 주기}
  Q3 -- 90일 이상 정적 --> SM[Secret Manager<br/>예 cloud KMS / Vault]
  Q3 -- 자주 회전 --> Vault[CI Secret 또는 런타임 주입]
  Public --> Q4{도메인 노출 안전한가}
  Q4 -- 예 --> Ok[빌드 시 인라인]
  Q4 -- 아니오 --> Reclass[Server 전용으로 재분류]
  Server --> Scan[빌드 산출물 시크릿 스캔 필수]

7.1 NEXTPUBLIC 접두사의 위험성

# .env.local

# ✅ 클라이언트에 노출되어도 괜찮은 값 — NEXT_PUBLIC_ 접두사 사용
NEXT_PUBLIC_API_URL=https://api.my-app.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXT_PUBLIC_OBSERVABILITY_DSN=https://example.invalid/project

# ❌ 절대로 NEXT_PUBLIC_을 붙이면 안 되는 값
# NEXT_PUBLIC_DB_PASSWORD=...       → 브라우저에서 볼 수 있음!
# NEXT_PUBLIC_JWT_SECRET=...        → 브라우저에서 볼 수 있음!
# NEXT_PUBLIC_CLOUD_SECRET_KEY=...    → 브라우저에서 볼 수 있음!

# ✅ 서버 사이드에서만 사용하는 값 — 접두사 없이 설정
DATABASE_URL=postgresql://user:pass@host:5432/db
JWT_SECRET=super-secret-key-never-expose
CLOUD_SECRET_ACCESS_KEY=xxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx

7.2 환경 변수 검증 (Zod 활용)

// env.ts — 환경 변수 타입 안전성 확보
import { z } from 'zod'

// 서버 전용 환경 변수 스키마
const serverEnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  CLOUD_SECRET_ACCESS_KEY: z.string().min(1),
  NODE_ENV: z.enum(['development', 'production', 'test']),
})

// 클라이언트 환경 변수 스키마
const clientEnvSchema = z.object({
  NEXT_PUBLIC_API_URL: z.string().url(),
  NEXT_PUBLIC_GA_ID: z.string().optional(),
})

// 서버 환경 변수 검증 (빌드 시 실패하도록)
export const serverEnv = serverEnvSchema.parse(process.env)

// 클라이언트 환경 변수 검증
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
})

7.3 .env 파일 관리 원칙

# .gitignore — 환경 변수 파일은 반드시 Git에서 제외
.env
.env.local
.env.production
.env.*.local

# .env.example만 커밋 (값 없이 키만 포함)
!.env.example
# .env.example — 팀원 온보딩용 (실제 값은 절대 포함하지 않음)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-jwt-secret-min-32-chars-here
NEXT_PUBLIC_API_URL=http://localhost:8080
// 시크릿이 클라이언트 번들에 포함되었는지 검사하는 스크립트
// scripts/check-secrets-in-bundle.ts
import fs from 'fs'
import path from 'path'

const FORBIDDEN_PATTERNS = [
  /sk_live_[a-zA-Z0-9]+/, // Stripe 시크릿 키
  /AKIA[A-Z0-9]{16}/, // cloud provider access key
  /-----BEGIN (RSA )?PRIVATE KEY-----/, // 개인 키
  /postgresql:\/\/[^"'\s]+/, // DB 연결 문자열
]

function scanBundle(dir: string): void {
  const files = fs.readdirSync(dir, { recursive: true, encoding: 'utf-8' })
  const jsFiles = files.filter((f) => f.endsWith('.js') || f.endsWith('.js.map'))

  for (const file of jsFiles) {
    const content = fs.readFileSync(path.join(dir, file), 'utf-8')
    for (const pattern of FORBIDDEN_PATTERNS) {
      if (pattern.test(content)) {
        console.error(`🚨 번들에서 시크릿 감지: ${file} (패턴: ${pattern})`)
        process.exit(1)
      }
    }
  }
  console.log('✅ 번들에서 시크릿이 감지되지 않았습니다.')
}

scanBundle('.next/static')

8. 보안 헤더 운영 가이드

HTTP 응답 헤더를 통해 브라우저의 보안 동작을 제어할 수 있습니다.

일상 비유: 빌딩 출입 보안과 같습니다. 정문 단속(HSTS), 출입증 검사(CSP), 엘리베이터 접근 제어(Permissions-Policy), 외부인 동반 금지(Frame-Ancestors) — 각 헤더가 서로 보완해야 안전합니다.

보안 헤더 의존 관계 다이어그램

이 그림은 주요 보안 헤더들이 어떤 위협을 막고 서로 어떻게 보완하는지 보여줍니다.

flowchart TD
  Req[HTTP 요청] --> H1[HSTS<br/>HTTPS 강제]
  H1 --> H2[Content-Security-Policy<br/>스크립트/스타일 출처 제한]
  H2 --> H2a[script-src nonce]
  H2 --> H2b[require-trusted-types-for]
  H2 --> H2c[frame-ancestors<br/>X-Frame-Options 대체]
  H2 --> H2d[form-action]
  H1 --> H3[X-Content-Type-Options nosniff<br/>MIME 스니핑 차단]
  H1 --> H4[Referrer-Policy<br/>외부로 누출되는 정보 제한]
  H1 --> H5[Permissions-Policy<br/>카메라/마이크/결제 등 차단]
  H1 --> H6[Cross-Origin-Opener-Policy<br/>+ Embedder + Resource Policy]
  H6 --> Iso[Cross-Origin Isolation<br/>SharedArrayBuffer/precise timer 사용]
  H2c --> CJ[Clickjacking 방어]
  H2 --> XSS[DOM/Reflected XSS 차단]
  H3 --> MIME[MIME confusion 공격 차단]
  H6 --> Spectre[Spectre/사이드 채널 완화]

8.1 필수 보안 헤더 일람

헤더 설명 권장 값
Strict-Transport-Security HTTPS 강제 (HSTS) max-age=31536000; includeSubDomains; preload
Content-Security-Policy 스크립트·리소스 출처 제어 default-src 'self'; ... + require-trusted-types-for 'script' (CSP3)
X-Content-Type-Options MIME 타입 스니핑 방지 nosniff
X-Frame-Options 클릭재킹 방지 (iframe 삽입 차단). CSP frame-ancestors로 대체 권장 DENY 또는 SAMEORIGIN
Referrer-Policy Referer 헤더 정보 제한 strict-origin-when-cross-origin
Permissions-Policy 브라우저 기능(카메라, 마이크 등) 제어 아래 예시 참고
X-DNS-Prefetch-Control DNS 프리페치 제어 off (개인정보 보호)
Cross-Origin-Opener-Policy 팝업과 오프너 간 격리 same-origin (결제·OAuth 팝업이 있다면 restrict-properties)
Cross-Origin-Embedder-Policy 리소스 임베드 정책 require-corp (또는 credentialless)
Cross-Origin-Resource-Policy 자원이 다른 오리진에서 임베드되는 것 차단 same-origin 또는 same-site

8.2 Next.js 보안 헤더 설정

// next.config.ts
import type { NextConfig } from 'next'

const securityHeaders = [
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=31536000; includeSubDomains; preload',
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Permissions-Policy',
    // 사용하지 않는 브라우저 기능을 명시적으로 차단
    value: [
      'camera=()', // 카메라 사용 금지
      'microphone=()', // 마이크 사용 금지
      'geolocation=()', // 위치 정보 사용 금지
      'payment=()', // 결제 API 사용 금지
      'usb=()', // USB 접근 금지
      'interest-cohort=()', // 브라우저 광고 추적 실험 거부
    ].join(', '),
  },
  {
    key: 'X-DNS-Prefetch-Control',
    value: 'off',
  },
  {
    key: 'Cross-Origin-Opener-Policy',
    // 결제·OAuth 등 서드파티 팝업을 사용한다면 "restrict-properties"로 완화
    // (cross-origin isolation은 유지하면서 외부 팝업과의 제한적 상호작용 허용)
    value: 'same-origin',
  },
  {
    key: 'Cross-Origin-Resource-Policy',
    value: 'same-origin',
  },
]

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // 모든 경로에 보안 헤더 적용
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
  // X-Powered-By 헤더 제거 (서버 정보 노출 방지)
  poweredByHeader: false,
}

export default nextConfig

8.3 각 헤더 상세 설명

Strict-Transport-Security (HSTS)

브라우저에 "이 사이트는 항상 HTTPS로만 접속하라"고 지시합니다. 중간자 공격(MITM)과 SSL 스트리핑 공격을 방어합니다.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • max-age=31536000: 1년간 HTTPS 강제
  • includeSubDomains: 모든 서브도메인에도 적용
  • preload: 브라우저의 HSTS preload 목록에 등록 (hstspreload.org)

Referrer-Policy

다른 사이트로 이동할 때 Referer 헤더에 포함되는 정보를 제어합니다.

동작
no-referrer Referer 헤더를 전송하지 않음
origin 출처(origin)만 전송
strict-origin-when-cross-origin 같은 출처: 전체 URL, 다른 출처: origin만, HTTPS→HTTP: 미전송 (권장)

Permissions-Policy

브라우저 기능에 대한 접근을 세밀하게 제어합니다.

Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()
  • (): 완전히 차단
  • (self): 같은 출처에서만 허용
  • (self "https://trusted.com"): 같은 출처와 특정 도메인만 허용
  • *: 모든 출처 허용 (비권장)

9. 주의사항 및 흔한 실수

9.1 흔한 보안 실수 TOP 10

# 실수 위험도 올바른 방법
1 localStorage에 JWT 저장 🔴 높음 HttpOnly 쿠키 또는 인메모리 토큰 사용
2 dangerouslySetInnerHTML에 미검증 데이터 주입 🔴 높음 DOMPurify로 반드시 새니타이징
3 API 키를 NEXT_PUBLIC_으로 노출 🔴 높음 서버 사이드에서만 사용, 프록시 구성
4 CORS에서 origin: "*" 사용 🟡 중간 허용 출처를 명시적으로 지정
5 CSP 미설정 🟡 중간 프로덕션에 반드시 CSP 적용
6 의존성 버전 범위 지정자(^, ~) 🟡 중간 정확한 버전 고정 (save-exact)
7 .env 파일 Git 커밋 🔴 높음 .gitignore에 추가, .env.example만 커밋
8 에러 메시지에 스택 트레이스 노출 🟡 중간 프로덕션에서는 사용자 친화적 메시지만 표시
9 보안 헤더 미설정 🟡 중간 최소 HSTS, X-Frame-Options, nosniff 설정
10 Refresh Token 재사용 허용 🔴 높음 Token Rotation 적용, 재사용 시 모든 토큰 폐기

9.2 프로덕션 전 점검 사항

// ❌ 개발 환경에서만 허용되는 코드가 프로덕션에 남아있지 않은지 확인

// 1. console.log에 민감한 정보 출력
console.log('user token:', token) // 🚨 절대 금지
console.log('request body:', requestBody) // 🚨 개인정보 포함 가능

// 2. 디버그용 CORS 설정
app.use(cors({ origin: '*' })) // 🚨 프로덕션에서 사용 금지

// 3. 에러 상세 정보 노출
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack, // 🚨 프로덕션에서 스택 트레이스 노출 금지
  })
})

// ✅ 프로덕션 에러 핸들러
app.use((err, req, res, next) => {
  // 로그에는 상세 정보 기록
  logger.error('Internal error', { error: err, requestId: req.id })

  // 클라이언트에는 최소한의 정보만 전달
  res.status(500).json({
    error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
    requestId: req.id, // 디버깅을 위한 추적 ID만 제공
  })
})

9.3 서드파티 스크립트 관리

// ❌ 위험 — 외부 스크립트 무분별 삽입
<script src="https://unknown-analytics.com/tracker.js" />

// ✅ 안전 — Subresource Integrity (SRI)로 무결성 검증
<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossOrigin="anonymous"
/>

// Next.js에서의 서드파티 스크립트 관리
import Script from "next/script";

<Script
  src="https://analytics.example.com/tracker.js"
  strategy="afterInteractive" // 페이지 로드 후 로딩
/>

AI 보조 보안 설계 검증

AI 사용 정책과 검증 책임은 18. AI 개발 워크플로우를 따릅니다. AI가 만든 보안 제안은 threat model, 재현 테스트, 보안 owner 승인 없이 적용하지 않습니다.

시나리오 입력 AI 산출물 필수 검증
인증 로직 리뷰 auth flow, token storage, refresh path race condition/XSS/CSRF 후보 auth E2E, threat model 업데이트
CSP 정책 초안 외부 script/style/img 출처 목록 report-only CSP 후보 violation report, staged enforcement
의존성 점검 lockfile, SBOM, package diff 취약/미관리 dependency 후보 audit/provenance 확인, owner 승인
에러 노출 점검 error handler, API response fixture 정보 노출 위험 후보 production error snapshot test

체크리스트

XSS 방어

  • [ ] dangerouslySetInnerHTML을 사용 중인 곳에 DOMPurify가 적용되었나요?
  • [ ] href, src 등에 사용자 입력이 들어가는 곳에서 javascript: 프로토콜을 필터링하나요?
  • [ ] 웹 사이트 전체에 적절한 CSP 정책이 수립되었나요?
  • [ ] CSP에서 'unsafe-inline', 'unsafe-eval'을 최소화했나요?
  • [ ] require-trusted-types-for 'script'로 DOM XSS 싱크를 차단했나요? (Chrome/Edge 안정 지원)
  • [ ] 서버 사이드에서도 HTML 새니타이징을 적용하고 있나요?

토큰 관리 / 인증

  • [ ] 민감한 토큰이 localStorage가 아닌 HttpOnly 쿠키에 저장되어 있나요?
  • [ ] Access Token은 인메모리에만 보관하고 있나요?
  • [ ] Silent Refresh 로직에서 경쟁 상태(Race Condition)를 방지하고 있나요?
  • [ ] Refresh Token Rotation이 적용되어 있나요?
  • [ ] OAuth/OIDC는 RFC 9700 기준 Authorization Code + PKCE, exact redirect URI, state/nonce, issuer 검증을 따르나요?
  • [ ] JWT는 alg: none 금지, 알고리즘 화이트리스트, iss/aud/kid 검증 (RFC 8725)이 적용되어 있나요?
  • [ ] 신규 인증 플로우에 Passkeys(WebAuthn) 도입을 검토했고, 백업 인증 수단을 제공하나요?
  • [ ] 토큰 만료 시 적절한 재인증 UX가 제공되나요?

CSRF 방어

  • [ ] 쿠키에 SameSite=Lax 이상이 설정되어 있나요?
  • [ ] 상태 변경 API에 CSRF 토큰 또는 커스텀 헤더 검증이 적용되어 있나요?

CORS

  • [ ] Access-Control-Allow-Origin*가 아닌 구체적인 출처가 설정되어 있나요?
  • [ ] withCredentials 사용 시 출처가 명시되어 있나요?

의존성 보안

  • [ ] CI에서 npm ci를 사용하고 있나요?
  • [ ] npm audit이 CI 파이프라인에 통합되어 있나요?
  • [ ] 의존성 버전이 정확히 고정(save-exact)되어 있나요?
  • [ ] 새로운 의존성 추가 시 보안 리뷰를 거치나요?

환경 변수 및 시크릿

  • [ ] 서버 전용 시크릿에 NEXT_PUBLIC_ 접두사가 없나요?
  • [ ] .env 파일이 .gitignore에 포함되어 있나요?
  • [ ] 환경 변수가 Zod 등으로 타입 검증되고 있나요?
  • [ ] 빌드 결과물에 시크릿이 포함되지 않았는지 검사하나요?

보안 헤더

  • [ ] API 응답 헤더에 X-Content-Type-Options: nosniff가 포함되어 있나요?
  • [ ] Strict-Transport-Security (HSTS)가 설정되어 있나요?
  • [ ] CSP의 frame-ancestors 또는 X-Frame-Options로 클릭재킹을 차단했나요?
  • [ ] Referrer-Policy가 설정되어 있나요?
  • [ ] Permissions-Policy로 불필요한 브라우저 기능을 차단했나요?
  • [ ] Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy로 cross-origin 격리를 설정했나요?
  • [ ] OAuth/결제 팝업이 있다면 COOP 값으로 restrict-properties를 고려했나요?
  • [ ] X-Powered-By 헤더가 제거되었나요?

일반 보안

  • [ ] 입력 폼에 maxLength 제한 및 서버 사이드 검증이 중복으로 되어 있나요?
  • [ ] 프로덕션 빌드에서 console.log로 민감한 정보가 출력되지 않나요?
  • [ ] 에러 응답에 스택 트레이스가 노출되지 않나요?
  • [ ] 서드파티 스크립트에 SRI(Subresource Integrity)가 적용되어 있나요?
  • [ ] SBOM, provenance/attestation, checksum이 release artifact와 함께 보관되나요?

15. secretlint 운영 기준

시크릿은 코드 리뷰에서 사람이 찾기 어렵기 때문에 로컬 hook과 CI에서 모두 빠르게 실패해야 합니다.

15.1 기본 구성

{
  "rules": [
    {
      "id": "@secretlint/secretlint-rule-preset-recommend"
    }
  ]
}
  • .secretlintrc.json 또는 .secretlintrc.yml을 저장소 root에 둡니다.
  • .secretlintignore에는 generated artifact, public fixture처럼 의도적으로 검사하지 않을 경로만 넣습니다.
  • secretlint는 기본적으로 secret 값을 mask하므로 CI 로그에 원문 secret이 노출되지 않게 합니다.
  • .env.example에는 실제 값 대신 형식만 남기고, .env.local.gitignore와 secretlint 검사 범위에서 모두 관리합니다.

15.2 허용 목록 운영

  • false positive는 allowMessageIds 또는 rule option으로 좁게 허용합니다.
  • 허용 목록에는 owner, 이유, 만료일을 주석이나 ADR에 남깁니다.
  • credential처럼 실제 위험이 있는 값은 allow하지 않고 즉시 rotate한 뒤 git history 노출 여부를 확인합니다.

15.3 gate 배치

위치 명령 실패 시 처리
로컬 pre-commit secretlint "**/*" commit 차단, staged file에서 값 제거
PR CI secretlint --format=github "**/*" merge 차단, annotation으로 위치 확인
release secret scan + provenance 확인 artifact 승격 중단

실무 적용 가이드

언제 이 문서를 펼칠까

  • 인증, 결제, 개인정보, 외부 스크립트, HTML 삽입을 다룰 때
  • dependency audit 또는 secret scan 경고가 반복될 때
  • CSP, cookie, token 저장 위치를 결정해야 할 때

적용 순서

  1. 데이터 흐름에서 민감정보가 브라우저, 로그, URL, storage, third-party로 이동하는 지점을 표시한다.
  2. 인증 토큰 저장 위치와 만료/갱신/폐기 전략을 정한다.
  3. HTML 삽입, 외부 스크립트, iframe은 CSP와 Trusted Types 관점에서 검토한다.
  4. dependency, secret, license, provenance 검사를 CI에 둔다.
  5. 보안 예외는 owner와 만료일이 있는 RFC로만 허용한다.

함께 두는 파일

  • feature별 보안 adapter, validation schema, 보안 테스트를 기능 폴더에 둔다.
  • 공통 security helper는 낮은 수준의 encoder, sanitizer, header builder처럼 도메인 무관한 것만 둔다.
  • 환경 변수 schema와 사용처를 가까이 둬 노출 범위를 찾기 쉽게 한다.

흔한 실수

  • access token을 localStorage에 저장한다.
  • HTML sanitizer 없이 CMS/사용자 입력을 렌더링한다.
  • 보안 헤더를 인프라 문서에만 두고 실제 응답 검사를 하지 않는다.
  • AI나 로그에 원본 개인정보를 전달한다.

PR 완료 기준

  • [ ] secret scan과 dependency audit 결과가 있다.
  • [ ] 민감정보가 URL/log/client bundle에 남지 않는다.
  • [ ] CSP/header 변경 영향이 검증되었다.
  • [ ] 보안 예외에 owner와 만료일이 있다.

추천 항목 실행 우선순위 매핑

  • P1(7일 내) — CSP, 인증, 시크릿, 공급망 통제 중 하나를 작은 변경 1건에 적용하고 증거(security scan)를 남긴다.
  • P2(30일 내) — 웹 보안 기준을 팀 템플릿, 체크리스트, CI 중 한 곳에 고정한다.
  • P3(90일 내) — 차단된 secret 수, CSP report, 취약 dependency 수 추이를 보고 기준을 유지할지 조정할지 결정한다.
  • 완료 기준 — 보안 오너가 증거와 철회 조건을 확인했다는 기록을 남긴다.

추천 항목 실행 체크리스트

  • [ ] 1단계(7일) : CSP, 인증, 시크릿, 공급망 통제 적용 대상을 1개로 좁힌다.
  • [ ] 2단계(30일) : 증거(security scan, header capture, secretlint 결과, SBOM/attestation)를 PR, ADR, 회고 중 한 곳에 연결한다.
  • [ ] 3단계(60일) : 차단된 secret 수, CSP report, 취약 dependency 수가 기준 안에 들어왔는지 확인한다.
  • [ ] 문제 대응 : 미달성 사유와 다음 조치, 중단 여부를 같은 기록에 남긴다.

추천 항목 실행 운영 규칙

  • 실행 게이트 : 브라우저에 노출되는 값과 서버 전용 값을 먼저 분리한다.
  • 승인 체계 : 보안 오너가 영향 범위와 rollback 담당자를 적용 전에 확인한다.
  • 재개 조건 : 보안 헤더와 secret scan이 통과하면 배포 경로로 올린다.
  • 정지 조건 : credential 노출, unsigned artifact, high 취약점이 있으면 즉시 중단한다.
  • 리스크 점수 : 노출 표면, 권한 범위, 취약점 심각도로 산정한다.
  • 리더 승인자 : 보안 리드가 최종 승인 책임을 맡는다.
  • 승인 역할 : 웹 보안 작성자, 검토자, 운영 확인자를 분리해 기록한다.
  • 재평가 주기 : 릴리스 전과 dependency bump 후에 다시 검토한다.