Skip to content

✍️ 필사 모드: 프런트엔드 보안 2025 — XSS·CSRF·CSP·Trusted Types·JWT·OAuth·PKCE·Passkey·Supply Chain·SRI 완전 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

프롤로그 — "프런트엔드에 보안이 있나요?"

오래 전 질문. 2025년엔 이렇게 바꿀 수 있다. "프런트엔드 보안이 없으면 당신의 서비스도 없다."

2015년까지만 해도 "보안은 백엔드가 책임지고, 프런트엔드는 예쁘게 만들면 된다"는 인식이 팽배했다. 하지만 2016년 Equifax·2017년 Uber·2019년 British Airways·2023년 LastPass 사건의 공통점은 침해의 상당 부분이 프런트엔드 벡터를 타고 들어왔다는 점이다.

2025년 프런트엔드 보안의 전환점:

  1. Supply Chain 공격의 폭증event-stream(2018) → ua-parser-js(2021) → xz-utils(2024) → 2024년 polyfill.io 도메인 인수 후 악성코드 배포(수십만 사이트 동시 영향) → **"npm install은 공격 표면"**이 상식이 됨
  2. CSP Level 3 + Trusted Types의 대중화 — Chrome·Edge·Firefox 모두 정식 지원. Google·Shopify 등이 strict CSP 전사 도입
  3. Passkey의 실질 대체 — Apple·Google·Microsoft가 password의 종말을 공식 선언, 2024~2025년에 주요 서비스가 Passkey 기본 전환
  4. EU Cyber Resilience Act — 2024년 채택, 2027년 완전 적용. 소프트웨어 공급망의 보안 책임이 제조사(=서비스 운영자)에 법적 귀속

이번 글에서는 2025년 프런트엔드 보안의 전 영역을 13개 챕터로 정리한다.


1장 · 공격자의 관점 — 프런트엔드에서 노리는 것

공격자가 훔치고 싶은 것

  1. 세션 토큰 — 로그인 상태 탈취 → 계정 전체 장악
  2. 개인정보 — 신용카드·주민번호·주소·이메일
  3. CSRF를 통한 돈·권한 이동 — 사용자가 로그인된 상태에서 의도 없이 송금·권한 부여
  4. 브라우저를 코인 채굴 봇으로 — 크립토재킹
  5. 리다이렉션 — 피싱 사이트로 유도
  6. SEO 스팸 — 합법 사이트에 숨겨 링크를 삽입

공격 벡터 — 어떻게 들어오는가

  • XSS — 사용자 입력을 이스케이프 없이 렌더
  • CSRF — 로그인된 세션을 이용해 의도하지 않은 요청
  • Clickjacking — iframe으로 합법 UI 위에 투명한 공격 UI
  • Supply Chain — 의존성 패키지·CDN 스크립트 변조
  • Man-in-the-Middle — HTTP·취약한 TLS 통신 가로채기
  • Subdomain Takeover — 만료된 DNS 레코드로 하위 도메인 인수

2장 · XSS (Cross-Site Scripting) — 여전히 TOP 1

OWASP Top 10 순위는 해마다 바뀌지만, XSS는 2025년에도 여전히 상위. 단순해 보여도 깊고, 막혔다고 생각해도 새로운 변종이 나온다.

3가지 분류

유형설명예시
ReflectedURL 파라미터가 즉시 렌더?q=<script>...</script>
StoredDB에 저장된 공격 코드가 다시 렌더댓글·프로필에 스크립트
DOM-based서버를 거치지 않고 JS가 DOM 조작 중 발생location.hashinnerHTML 설정

기본 방어선 — 이스케이프와 컨텍스트

React·Vue·Svelte는 기본적으로 {value}를 HTML-escape해서 렌더한다. 그래서 리액트 시대에는 **"그냥 {foo}로만 쓰면 안전"**이 상식이 되었다.

// Safe — React가 자동 escape
return <div>{userInput}</div>;

// Danger — 탈출구
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;

그래도 뚫리는 이유

  1. dangerouslySetInnerHTML·v-html·{@html}을 어쩔 수 없이 쓸 때
  2. a.href = userUrljavascript:void(0)이라면?
  3. ref.current.innerHTML = ... 같은 DOM API 직접 조작
  4. 관리자 페이지·리치 에디터 등 "HTML 허용"이 필수인 영역

DOMPurify — 화이트리스트 기반 Sanitize

import DOMPurify from "dompurify";

const safe = DOMPurify.sanitize(userHtml, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
  ALLOWED_ATTR: ["href"],
  ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
});

return <div dangerouslySetInnerHTML={{ __html: safe }} />;

**"허용한 것만 통과"**가 원칙. 블랙리스트는 반드시 뚫린다.

링크 URL 검증

function safeUrl(url: string): string {
  try {
    const u = new URL(url, window.location.href);
    if (u.protocol === "http:" || u.protocol === "https:") {
      return u.toString();
    }
  } catch {}
  return "#";
}

javascript:·data:·vbscript: 프로토콜을 절대 허용하지 말 것.


3장 · CSP (Content Security Policy) — 2025 실전

CSP는 브라우저가 로드할 수 있는 리소스의 출처를 명시적으로 제한하는 HTTP 헤더. XSS의 피해를 근본적으로 차단하는 강력한 방어선.

기본 헤더

Content-Security-Policy: default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-{RANDOM}' https:;

2025 베스트 프랙티스 — strict-dynamic + nonce

2010년대의 whitelist 기반 CSP는 대부분 우회 가능하다는 Google 연구(CSP Is Dead, Long Live CSP, 2016)에 따라 업계는 **nonce + strict-dynamic**으로 이동.

<!-- 서버가 요청마다 랜덤 nonce 생성 -->
<script nonce="abc123">...</script>
<script nonce="abc123" src="/app.js"></script>
Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-abc123' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  report-uri /csp-report;

strict-dynamic의 의미: "nonce/hash로 로드된 스크립트는 또 다른 스크립트를 로드할 수 있다." 이 덕분에 whitelist를 포기하고도 동적 로딩이 가능.

Report-Only 모드로 점진 도입

갑자기 strict CSP를 켜면 대부분의 서비스는 당장 망가진다. 먼저 Content-Security-Policy-Report-Only 헤더로 차단하지 않고 위반만 리포트하다가 이슈를 정리한 후 본 헤더로 이동.

Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint;
Reporting-Endpoints: csp-endpoint="/csp-report"

Next.js 15 예시

// middleware.ts
import { NextResponse } from "next/server";

export function middleware(request: Request) {
  const nonce = crypto.randomUUID().replace(/-/g, "");
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    frame-ancestors 'none';
  `.replace(/\s+/g, " ").trim();

  const res = NextResponse.next();
  res.headers.set("Content-Security-Policy", csp);
  res.headers.set("x-nonce", nonce);
  return res;
}

필수 동반 헤더

  • Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), camera=(), microphone=()

4장 · Trusted Types — DOM-based XSS의 종언

CSP Level 3의 강력한 신기능. 브라우저가 "위험한 sink에 문자열을 못 넣게" 원천 봉쇄.

어떻게 동작하나

innerHTML, outerHTML, eval, setTimeout(string) 같은 sink에 일반 문자열을 할당하면 예외 발생. 반드시 TrustedTypePolicy로 생성한 객체여야만 통과.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify;
// Bad — TypeError: Failed to set innerHTML on Element
el.innerHTML = userInput;

// Good — 정책 객체로 포장
const policy = trustedTypes.createPolicy("dompurify", {
  createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userInput);

효과

  • 전체 코드베이스에서 sanitize 누락 발견: 예외로 즉시 드러남
  • 서드파티 라이브러리도 강제 준수: 우회 불가
  • DOM-based XSS 사실상 제거

도입 팁

  1. require-trusted-types-for 'script'Report-Only로 먼저 켜기
  2. 자사 코드의 sink를 모두 TrustedType으로 이동
  3. 문제 되는 서드파티 라이브러리 대체 또는 wrapper 작성
  4. 정식 헤더로 전환

2025년 기준 Chrome·Edge 지원. Firefox·Safari는 아직 보류(ESM import만 지원).


5장 · CSRF (Cross-Site Request Forgery)

공격 시나리오

사용자가 은행 사이트에 로그인된 상태로 공격자의 사이트를 방문 → 공격자의 페이지가 은행 API를 몰래 호출 → 브라우저는 쿠키를 자동으로 첨부 → 송금 실행.

방어 1 — SameSite 쿠키

Set-Cookie: session=abc; Secure; HttpOnly; SameSite=Lax; Path=/
SameSite의미
StrictCross-site 요청에 절대 쿠키 미전송 (로그인 지속성 떨어짐)
Lax일반 링크 클릭 시는 허용, POST·iframe·XHR은 차단
None모든 요청에 쿠키 전송 (Secure 필수)

Chrome은 2020년부터 기본값을 Lax로 전환. 2025년 모든 주요 브라우저 동일.

방어 2 — CSRF Token

<form method="POST">
  <input type="hidden" name="csrf_token" value="{SERVER_GENERATED}" />
  ...
</form>

서버는 요청마다 토큰 검증. SameSite=Lax로는 막히지 않는 edge case(상태 변경 GET)도 커버.

  • 쿠키와 헤더·body에 같은 토큰을 실어 보내고 서버가 비교
  • 쿠키를 자동 전송하는 CSRF는 헤더를 설정할 수 없기 때문에 차단

방어 4 — Origin·Referer 헤더 검증

서버가 요청의 Origin 헤더를 화이트리스트와 비교.


6장 · Clickjacking과 frame-ancestors

공격자가 자신의 사이트에 피해자의 iframe을 얹고, 투명하게 만들어 "좋아요 버튼"을 누르면 실제로는 "이체" 버튼이 눌리게.

방어

X-Frame-Options: DENY
# 또는 CSP
Content-Security-Policy: frame-ancestors 'none';

특정 파트너 사이트에서만 허용:

Content-Security-Policy: frame-ancestors 'self' https://partner.example.com;

CSP frame-ancestorsX-Frame-Options를 대체한다 (CSP가 우선). 2025년 두 헤더 모두 설정 권장 (구형 브라우저 호환).


7장 · 쿠키·JWT·세션 관리

쿠키 보안 플래그 — 반드시 모두 켤 것

Set-Cookie:
  session=abc123;
  Secure;           # HTTPS only
  HttpOnly;         # JS 접근 불가 (XSS 탈취 방지)
  SameSite=Lax;     # CSRF 방어
  Path=/;
  Max-Age=3600;

JWT를 어디에 저장할까 — 영원한 논쟁

위치장점단점
localStorage쉽다XSS에 완전 노출
sessionStorage탭 닫으면 사라짐역시 XSS에 노출
HttpOnly CookieJS 접근 불가CSRF 고민 필요, 토큰 크기 제한
Memory(JS 변수)새로고침하면 사라짐UX 나쁨

2025 권장: Access Token은 메모리 또는 HttpOnly 쿠키 + Refresh Token은 HttpOnly·SameSite=Strict 쿠키.

// 서버 → 클라이언트 응답
res.cookie("access_token", jwt, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 15 * 60 * 1000, // 15분
});

res.cookie("refresh_token", refresh, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  path: "/auth/refresh",
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});

JWT의 함정

  • 알고리즘 혼동 공격: alg: none 또는 HS256↔RS256 혼선
  • 긴 TTL: 30일짜리 JWT는 해킹당하면 30일간 무효화 불가능
  • Refresh Token Rotation: 갱신할 때마다 새 refresh 발급, 이전 것 즉시 무효화

8장 · OAuth 2.1·OIDC·PKCE

OAuth 2.1 — 2024년 Best Current Practice로 통합

2025년의 OAuth는 OAuth 2.1. 주요 변경:

  • Implicit Flow 폐기
  • PKCE 필수 (Public Client)
  • Refresh Token Rotation 필수
  • 정확한 Redirect URI 매칭

PKCE (Proof Key for Code Exchange)

Public Client(SPA·모바일 앱)에서 client_secret을 숨길 수 없는 문제를 해결한 확장.

  1. 클라이언트가 code_verifier(랜덤 문자열) 생성
  2. code_challenge = SHA256(code_verifier) 계산
  3. 인증 요청 시 code_challenge 전송
  4. 토큰 교환 시 code_verifier 전송
  5. 서버가 SHA256 해시가 일치하는지 검증
function generatePKCE() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = base64url(
    new Uint8Array(
      await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
    )
  );
  return { verifier, challenge };
}

OIDC (OpenID Connect)

OAuth 2.1 위에 얹은 표준 ID 레이어. id_token(JWT)에 사용자 정보 내장. Google·Microsoft·Apple Sign In 모두 OIDC.


9장 · Passkey·WebAuthn — 비밀번호의 종언

2022년 Apple·Google·Microsoft 공동 선언 후 2024~2025년에 본격 확산. Passkey는 FIDO2 + WebAuthn + Sync의 결합.

작동 원리

  1. 기기가 공개/개인 키 쌍 생성
  2. 개인 키는 Secure Enclave·TPM에 저장 (Passkey는 iCloud/Google Password Manager로 동기)
  3. 로그인 시 서버의 챌린지를 개인 키로 서명, 서버는 공개 키로 검증

장점

  • Phishing 내성 — 도메인별로 키가 분리됨, 가짜 사이트에서 동작 안 함
  • 서버에 비밀 없음 — 공개 키만 저장, 유출되어도 안전
  • Credential Stuffing 불가 — 다른 사이트 비밀번호 재사용 없음
  • 생체 인증 UX

WebAuthn 코드 예시

// Registration
const cred = await navigator.credentials.create({
  publicKey: {
    challenge: new Uint8Array(32), // from server
    rp: { name: "Example", id: "example.com" },
    user: {
      id: new TextEncoder().encode(user.id),
      name: user.email,
      displayName: user.name,
    },
    pubKeyCredParams: [
      { type: "public-key", alg: -7 },   // ES256
      { type: "public-key", alg: -257 }, // RS256
    ],
    authenticatorSelection: {
      residentKey: "required",
      userVerification: "preferred",
    },
  },
});

// Authentication
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: new Uint8Array(32),
    allowCredentials: [
      { id: credentialIdFromServer, type: "public-key" },
    ],
  },
});

라이브러리

도입 체크리스트

  1. rp.id 도메인 정확히 설정
  2. Resident key(Device-bound/Synced) 전략
  3. Fallback: 비밀번호 + 이메일 OTP
  4. Account Recovery 경로 (기기 분실 시)

10장 · Supply Chain Attack — npm의 어두운 면

현실

  • 평균 node_modules1,500개 이상의 의존성 (4~5단계 transitive 포함)
  • 매주 수만 개의 패키지 버전이 발행
  • 2024년 polyfill.io 악성 코드 사건: 하나의 도메인 인수로 수십만 사이트 동시 감염

방어선

1. Lock 파일과 무결성 검증

npm ci                          # package-lock.json 엄격 준수
pnpm install --frozen-lockfile  # 동일

Lock 파일에 integrity hash(SHA-512)가 기록되어, 공격자가 레지스트리에서 같은 버전에 다른 내용물을 넣어도 차단.

2. 의존성 스캔 자동화

  • Snyk
  • Socket.dev — 실시간 악성 행위 탐지 (네트워크·파일 시스템 접근 패턴 분석)
  • GitHub Dependabot / Renovate
  • npm audit (기본, 하지만 제한적)

3. postinstall 스크립트 차단

npm install --ignore-scripts
# 또는 pnpm
pnpm install --ignore-scripts

많은 공급망 공격이 postinstall 스크립트로 동작. CI에서 기본 차단 권장.

4. 의존성 최소화

  • is-number, isarray 같은 1줄짜리 마이크로 패키지 의존 지양
  • 한 프로젝트에 lodash·ramda·underscore 중복 방지

5. 내부 레지스트리 미러

  • Verdaccio, JFrog Artifactory, GitHub Packages로 의존성 프록시
  • 외부 레지스트리 장애·악성 버전 공개 시 방어선

11장 · SRI·외부 스크립트·CDN 보안

Subresource Integrity (SRI)

외부 CDN 스크립트의 해시를 미리 기록해, 변조되면 로드 거부.

<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"
></script>

SRI는 변조 방어에는 완벽하지만, 원본이 이미 악성이면 막지 못한다. polyfill.io 사건이 그 사례.

2025 권장: 가급적 서드파티 CDN 의존을 줄이고 자사 CDN으로 프록시·미러링 또는 번들에 포함.

Self-hosting 3rd party

  • Google Analytics → @next/third-parties 또는 Partytown
  • Google Fonts → @next/font로 자동 self-host
  • 소셜 위젯 → 정적 HTML로 대체 가능한지 검토

12장 · HTTPS·인증서·HSTS

HTTPS는 기본, 예외 없음

2020년대 후반 이후 HTTP는 사실상 없다. 모든 주요 브라우저가 HTTP 사이트에 경고.

HSTS (HTTP Strict Transport Security)

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • max-age: 2년 이상 권장
  • includeSubDomains: 모든 하위 도메인에 적용
  • preload: HSTS Preload List 등록 시 브라우저가 hardcoded로 HTTPS 강제

인증서 자동화

  • Let's Encrypt + certbot (무료·자동 갱신)
  • AWS ACM, Cloudflare, Vercel은 자동 관리

TLS 1.3 의무화

2025년 기준 TLS 1.0·1.1은 폐기. TLS 1.2 최소, TLS 1.3 권장.


13장 · 체크리스트·안티패턴·다음 글 예고

출시 전 보안 체크리스트 (15개)

  1. HTTPS 전용 + HSTS Preload
  2. CSP strict-dynamic + nonce로 설정
  3. Trusted Types Report-Only로 실험 시작
  4. X-Content-Type-Options, Referrer-Policy, Permissions-Policy 설정
  5. CSRF Token 또는 SameSite=Lax/Strict 쿠키
  6. 쿠키 HttpOnly + Secure + SameSite
  7. JWT는 메모리·HttpOnly 쿠키, localStorage 금지
  8. OAuth 2.1 + PKCE로 SPA 인증
  9. Passkey·WebAuthn 도입 고려
  10. npm ci / pnpm install --frozen-lockfile CI 필수
  11. Dependabot·Renovate·Snyk·Socket.dev 의존성 스캔
  12. SRI 외부 스크립트
  13. DOMPurify로 사용자 HTML sanitize
  14. CVE 대응 프로세스 (탐지→평가→패치→공지)
  15. Penetration Test 연 1회 이상

보안 안티패턴 TOP 10

  1. localStorage에 JWT 저장
  2. dangerouslySetInnerHTML을 sanitize 없이 사용
  3. eval, new Function(string), setTimeout("code")
  4. http:// 하드코딩된 링크
  5. Mixed Content — HTTPS 페이지에서 HTTP 이미지
  6. <a target="_blank"> without rel="noopener noreferrer" — Tab-napping 공격
  7. 모든 사용자에게 detailed error 노출
  8. 개인 비밀키·API 토큰을 프런트엔드 번들에 포함
  9. Wildcard CORS(Access-Control-Allow-Origin: *) + credentials
  10. 업데이트 안 하는 의존성 — "잘 돌아가는데 왜 건드려"

다음 글 예고 — Season 6 Ep 10: "상태 관리의 르네상스"

보안까지 탄탄해졌다면 다음은 코드 아키텍처. Ep 10은 2025년의 상태 관리.

  • Zustand·Jotai·Valtio·Redux Toolkit·Recoil 2025 현황
  • TanStack Query(React Query)의 서버 상태 혁명
  • RSC 시대에 바뀐 클라이언트 상태 범위
  • Signal(Solid·Preact·Angular·Vue Vapor·Svelte 5) vs 기존 React Hook 모델
  • XState·State Machine 재조명
  • URL state vs Local state vs Server state vs Form state
  • Optimistic Update와 Rollback
  • Persist·Hydration 전략
  • 한국적 맥락: 폼·위저드·다단계 결제 흐름 설계

"상태는 물리학이 아니라 철학이다. 어디에 살게 할지, 누가 소유할지, 어떻게 동기화할지 먼저 결정하자."

다음 글에서 만나자.


"보안은 제품에 붙는 기능이 아니다. 기본값이 안전해야 하고, 실수해도 치명상이 나지 않아야 하고, 의심이 들면 차단이 기본이어야 한다. Security by default, fail closed, defense in depth — 이 세 문장을 기억하자."

현재 단락 (1/303)

오래 전 질문. 2025년엔 이렇게 바꿀 수 있다. **"프런트엔드 보안이 없으면 당신의 서비스도 없다."**

작성 글자: 0원문 글자: 11,890작성 단락: 0/303