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가지 분류

| 유형 | 설명 | 예시 |

| --- | --- | --- |

| **Reflected** | URL 파라미터가 즉시 렌더 | `?q=<script>...</script>` |

| **Stored** | DB에 저장된 공격 코드가 다시 렌더 | 댓글·프로필에 스크립트 |

| **DOM-based** | 서버를 거치지 않고 JS가 DOM 조작 중 발생 | `location.hash`로 `innerHTML` 설정 |

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

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 = userUrl`이 `javascript:void(0)`이라면?

3. `ref.current.innerHTML = ...` 같은 DOM API 직접 조작

4. 관리자 페이지·리치 에디터 등 "HTML 허용"이 필수인 영역

DOMPurify — 화이트리스트 기반 Sanitize

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 생성 -->

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

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 | 의미 |

| --- | --- |

| `Strict` | Cross-site 요청에 절대 쿠키 미전송 (로그인 지속성 떨어짐) |

| `Lax` | 일반 링크 클릭 시는 허용, POST·iframe·XHR은 차단 |

| `None` | 모든 요청에 쿠키 전송 (Secure 필수) |

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

방어 2 — CSRF Token

...

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

방어 3 — Double Submit Cookie

- 쿠키와 헤더·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-ancestors`가 `X-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 Cookie** | JS 접근 불가 | 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" },

],

},

});

라이브러리

- [SimpleWebAuthn](https://simplewebauthn.dev) — 서버·클라이언트 통합

- [Passkeys.dev](https://passkeys.dev) — Google 공식 가이드

도입 체크리스트

1. `rp.id` 도메인 정확히 설정

2. Resident key(Device-bound/Synced) 전략

3. Fallback: 비밀번호 + 이메일 OTP

4. Account Recovery 경로 (기기 분실 시)

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

현실

- 평균 `node_modules`에 **1,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 스크립트의 해시를 미리 기록해, 변조되면 로드 거부.

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](https://hstspreload.org) 등록 시 브라우저가 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/297)

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

작성 글자: 0원문 글자: 11,715작성 단락: 0/297