필사 모드: 프런트엔드 보안 2025 — XSS·CSRF·CSP·Trusted Types·JWT·OAuth·PKCE·Passkey·Supply Chain·SRI 완전 가이드
한국어프롤로그 — "프런트엔드에 보안이 있나요?"
오래 전 질문. 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년엔 이렇게 바꿀 수 있다. **"프런트엔드 보안이 없으면 당신의 서비스도 없다."**