1. 개요
CMS 는 관리자가 쓴 리치 텍스트를 저장하고 렌더한다. 즉 사용자 입력 HTML 을 다루는 화면이다. XSS 한 번이면 localStorage 의 토큰이 통째로 나간다. sanitize 를 아무리 강화해도 “한 번” 을 막을 수 있다고 확신하기 어렵다. 그래서 토큰을 JS 가 읽지 못하게 만드는 쪽을 택했다. 이 글은 그 구조와, 구조가 만든 부작용 하나를 다룬다.
2. 핵심 내용
2-1. 첫 겹: 관리자 도메인은 별도 접근 제어 뒤에
관리자 서브도메인은 앱에 도달하기 전에 엣지에서 한 번 더 인증을 거친다. 앱 코드에는 이 층이 보이지 않는다. 그래서 앱 코드만 보고 “관리자 페이지가 공개돼 있다” 고 오판하기 쉽다. 반대로 이 층이 있다고 앱 쪽 인증을 느슨하게 해도 안 된다. 엣지 설정은 클릭 한 번으로 꺼질 수 있다.
2-2. 둘째 겹: 미들웨어의 서버 리다이렉트
Next.js 미들웨어가 관리자 경로 요청에 인증 쿠키가 하나도 없으면 로그인 페이지로 서버 리다이렉트한다. 클라이언트 JS 가 뜨기 전에 판정하므로 보호된 화면의 HTML 이 한 바이트도 나가지 않는다.
2-3. 셋째 겹: BFF 프록시와 httpOnly 쿠키
브라우저는 토큰을 갖지 않는다. 로그인하면 Next 서버가 백엔드에 서버 간 호출로 인증하고, 받은 토큰을 httpOnly 쿠키로 브라우저에 준다. 이후 브라우저는 same-origin 의 /api/proxy/* 로만 요청을 보내고, 프록시(Route Handler)가 쿠키를 읽어 Authorization: Bearer 로 바꿔 백엔드에 중계한다.
sequenceDiagram participant B as 브라우저 participant N as Next 서버 (BFF) participant S as Spring 백엔드 B->>N: POST /admin/login (Server Action) N->>S: 서버 간 인증 호출 S-->>N: AccessToken + RefreshToken N-->>B: Set-Cookie httpOnly ×2 B->>N: GET /api/proxy/admin/menus (쿠키 자동) N->>S: GET /admin/menus + Authorization: Bearer S-->>N: 200 N-->>B: 200
JS 는 쿠키를 읽을 수 없고, 토큰을 헤더에 붙일 일도 없다. XSS 가 성공해도 공격자가 얻는 건 “그 세션 동안 프록시를 통해 요청을 보낼 수 있는 능력” 이고, 토큰 자체를 들고 나갈 수는 없다.
2-4. 백엔드: 무상태 JWT + 회전
백엔드는 무상태 JWT 를 검증한다. 리프레시 토큰은 Redis 에 사용자 단위로 저장하고, 재발급마다 회전(RTR) 한다. 한 번 쓴 리프레시 토큰이 다시 오면 탈취로 보고 거부한다. 로그아웃은 블랙리스트. 권한은 ADMIN > EDITOR 계층.
회전을 도입하면 클라이언트 쪽에 새 문제가 생긴다. 탭 두 개가 동시에 401 을 받고 동시에 재발급을 요청하면, 둘째 요청이 “이미 쓴 토큰” 으로 거부된다. 프록시에서 리프레시 토큰 값을 키로 in-flight Promise 를 공유하는 single-flight 로 풀었다. 첫 요청이 재발급하는 동안 나머지는 그 결과를 기다린다.
2-5. 구조가 만든 구멍: 서버 간 호출은 IP 가 하나다
관리자 로그인은 Server Action 이다. Next 서버가 백엔드를 클러스터 내부 주소로 직접 부른다. 이 경로는 Cloudflare 도 게이트웨이도 지나지 않는다. 백엔드가 보는 클라이언트 IP 는 Next 파드 IP 하나 다.
로그인 레이트리밋이 IP 단위였으니, 전 관리자의 로그인 시도가 한 IP 로 뭉쳤다. 한 사람이 비밀번호를 몇 번 틀리면 다른 사람도 잠긴다.
해법은 다른 프로젝트와 같다. Next 서버가 내부 호출에 공유 시크릿 헤더와 원 클라이언트 IP 헤더를 싣고, 백엔드는 시크릿이 맞을 때만 그 IP 를 믿는다. 한 가지 배운 것은, 이 헤더를 CF-Connecting-IP 이름으로 재사용하면 안 된다는 점이다. 그 요청이 어떤 이유로든 Cloudflare 를 다시 지나면 엣지가 위조로 판정해 403 을 낸다.
2-6. 응답 봉투와 로그인 타이밍
백엔드는 모든 JSON 응답을 {status: "success", data} 로 감싼다. 프론트의 axios 인터셉터가 벗긴다. 그런데 로그인은 axios 를 안 탄다. Server Action 이 fetch 로 직접 부른다. 이 경로는 봉투를 스스로 벗겨야 한다. 이 이야기는 5편의 장애로 이어진다.
로그인 실패 응답 시간은 사용자 존재 여부와 무관하게 맞췄다. 없는 사용자에도 더미 해시로 비밀번호 비교를 한 번 돌린다. 응답 시간 차이로 계정 존재를 알아내는 것을 막는 흔한 기법인데, 넣는 건 열 줄이고 빼먹으면 영원히 모른다.
3. 마무리
요약
- CMS 는 사용자 HTML 을 렌더한다. sanitize 강화보다 “토큰을 JS 가 못 읽게” 가 근본적이다.
- BFF 프록시 + httpOnly 쿠키. 브라우저는 토큰을 갖지 않는다.
- RTR 을 도입하면 재발급 동시성이 생긴다. single-flight 로.
- 서버 간 호출은 IP 가 하나다. 레이트리밋이 IP 단위면 전 사용자가 뭉친다.
다음은 문의 폼 봇 방어다.