1편과 2편을 정리한 글이지만, 완성된 결론만 보여주다 보니 그 결론에 도달할 때까지 가장 시간을 많이 쓴 지점들이 빠졌습니다. 이 글은 그 사이드 노트입니다. “이게 왜 이렇게 되는 거지” 또는 “이 결정이 맞나” 하며 며칠씩 머물렀던 다섯 지점을 따로 뽑았습니다.
회고(3편)가 “다시 한다면 어떻게 할지”라면, 이 글은 “그때 어디서 막혔는지” 입니다.
1. 토큰 저장 위치 결정 — 세 선택지가 머릿속에 잘 안 그려졌던 이유
1편의 세 가지 전략 비교는 결론만 보면 “장단점 정리해서 골랐다”는 깔끔한 의사결정처럼 보이지만, 실제로는 셋 다 추상적으로만 와닿아서 한참을 헤맸습니다.
처음 머리에 떠오른 질문들은 이런 것들이었습니다:
localStorage가 위험하다는 건 들어봤는데, 얼마나 위험한가? 7일짜리 refreshToken이 노출되면 구체적으로 무슨 일이 일어나나?- 서버 세션이 “전통적이고 안전한 정공법”이라는데, 그게 왜 이 프로젝트엔 과한가? “백엔드 수정 비용”이라는 말이 너무 추상적이다.
- HttpOnly 하이브리드가 “절충안”이라는데, 정확히 뭘 절충하는 건가? 보안과 무엇을 맞바꾸는가?
이 셋이 답이 안 나오는 진짜 이유는 무엇을 막을지 먼저 정하지 않았기 때문이었습니다. 셋을 추상적으로 비교하면 영원히 답이 안 나옵니다. “공격자가 어떤 경로로 무엇을 노릴 수 있는가”를 먼저 그려야 각 선택지가 어디서 다른지가 보입니다.
이 프로젝트에서 무엇을 막을지 명시적으로 적어보니:
- 막을 것 (현실적 위협): XSS를 통한 토큰 탈취 — 공격자가 우리 사이트에 악성 JavaScript를 몰래 심어, 사용자 브라우저에서 그 코드가 실행되면서 저장된 토큰을 빼가는 시나리오. 가장 현실적인 경로는 우리가 가져다 쓰는 npm 패키지 중 하나에 악성 코드가 섞이는 경우(소위 supply chain 사고)
- 안 막을 것 (범위 밖):
- CSRF (사용자가 우리 사이트에 로그인된 상태를 노려, 다른 사이트가 사용자 모르게 우리 사이트로 요청을 보내 의도하지 않은 행위를 시키는 공격) — 이 프로젝트는 accessToken을 쿠키가 아니라 HTTP
Authorization헤더로 보내는 구조라, 외부 사이트가 사용자의 토큰을 자동 첨부할 방법이 없음. 구조상 성립 불가 - MITM (사용자와 서버 사이 통신을 중간에서 가로채는 공격, 예: 공공 와이파이 도청) — 모든 트래픽을 HTTPS로 암호화하므로 중간에서 내용을 못 읽음. 인프라 레벨 대응 영역이라 애플리케이션 코드로 다룰 일이 아님
- CSRF (사용자가 우리 사이트에 로그인된 상태를 노려, 다른 사이트가 사용자 모르게 우리 사이트로 요청을 보내 의도하지 않은 행위를 시키는 공격) — 이 프로젝트는 accessToken을 쿠키가 아니라 HTTP
이렇게 정리하고 나니 세 선택지가 갑자기 명확해졌습니다:
- localStorage: XSS 한 줄에 7일짜리 토큰 노출 → 막아야 할 공격 중 핵심을 정면으로 노출
- 서버 세션: 백엔드 인증 모델을 통째로 바꾸는 작업 → 막아야 할 범위 대비 ROI가 안 나옴
- HttpOnly 하이브리드: refreshToken은 JS에서 못 읽음 → 막아야 할 핵심 시나리오를 정확히 차단
Tip (배운 점)
“세 선택지를 비교한다”가 아니라 “무엇을 막을지 먼저 정하고, 그것을 가장 잘 막는 선택을 찾는다” 가 순서. 선택지부터 보면 비교의 축이 너무 많아서 결정이 안 됨.
2. 토큰 관리가 두 레이어로 나뉜 구조 정당화 — “로그인은 백엔드, 갱신은 Next.js”가 일관성 없어 보였던 문제
1편의 채택안에서 결국 자리잡은 구조는:
- 로그인 → 백엔드가 직접
Set-Cookie - 갱신·로그아웃 → Next.js API Route가 쿠키 처리
이게 처음엔 너무 일관성 없어 보였습니다. “한 곳으로 통일하든가, 한 곳을 안 거치든가 둘 중 하나여야 하지 않나?” 라는 본능적 거리낌이 있었습니다.
며칠 헤매다 깨달은 건 — 이렇게 토큰 관리가 두 레이어로 나뉜 건 사실 두 개의 서로 다른 트레이드오프의 흔적이었다는 점입니다.
- 로그인: “백엔드가 자격증명 검증 → 토큰 발급 → 응답”이 한 흐름이라
Set-Cookie까지 함께 붙이는 게 자연스러움. Next.js를 끼우면 의미 없이 hop이 늘어남 - 갱신·로그아웃: 점진적 리팩토링 과정에서 “Authorization 헤더 기반 API와 쿠키 기반 인증의 책임 경계를 정리”하려 Next.js로 옮겨짐. 결국 과도기적 결정
각 결정의 맥락이 다른데 결과만 보면 토큰 관리가 흩어진 것처럼 보일 뿐, 각자 자기 자리에서 합리적인 선택이었습니다. 일관성이 깨진 게 아니라, 일관성을 추구한 두 방향이 각자의 합리성을 따라간 결과입니다.
다만 3편의 회고에서 적었듯, 다시 한다면 갱신·로그아웃도 백엔드로 모아 토큰 관리를 한 곳으로 통일하겠다는 게 결론입니다. “각자 자리에서 합리적이지만 전체로 보면 토큰 관리가 두 레이어에 흩어진” 구조는 운영하면서 디버깅 비용을 꾸준히 갉아먹습니다.
Tip (배운 점)
일관성 없어 보이는 구조를 마주하면, 먼저 “각 결정의 맥락이 같은가” 를 확인. 다른 맥락에서 내려진 결정들이 모인 것이라면 비일관이 아니라 트레이드오프의 흔적. 다만 그 흔적이 운영 비용을 만든다면 통일이 답.
3. HttpOnly의 진짜 보호 범위 — “JS에서 못 읽는다”가 “공격자가 못 쓴다”가 아니다
처음에는 머릿속에 “HttpOnly = 안전” 도식이 박혀 있었습니다. JS에서 쿠키 값을 못 읽으니까 XSS가 뚫려도 토큰은 무사하다고 단순하게 믿었습니다.
2편의 XSS 활성 상태 분석을 쓰면서 가장 충격이었던 게 바로 이 도식이 틀렸다는 점이었습니다.
공격자의 XSS 스크립트가 같은 도메인에서 실행 중→ fetch('/api/auth/refresh')만 호출하면→ 브라우저가 HttpOnly 쿠키를 자동 첨부 (same-origin)→ 응답 body의 새 accessToken은 JS가 읽을 수 있음→ 공격자가 그 accessToken으로 사용자처럼 행동HttpOnly는 “JS가 토큰 값을 직접 읽는 것”을 막을 뿐, 브라우저가 쿠키를 자동 첨부하는 동작은 그대로입니다. 그 사실을 받아들이는 데 한참 걸렸습니다.
그렇다고 HttpOnly가 무의미한 건 아니라는 걸 정리하는 것도 어려웠습니다. localStorage 대비 결정적인 차이가 있긴 있는데, 그게 정확히 무엇인지 한 줄로 안 나왔습니다. 결국 정리한 건:
| localStorage | HttpOnly Cookie | |
|---|---|---|
| 토큰 값 자체 | 외부 서버로 유출 가능 | 외부로 유출 불가 |
| 공격 가능 조건 | 토큰만 있으면 어디서든 | XSS 활성 동안, 피해자 브라우저에서만 |
| 사용자가 페이지 닫으면 | 공격자 여전히 7일 사용 | 공격 종료 |
즉 HttpOnly의 진짜 가치는 **“외부 유출 차단”**이지 **“사용 차단”**이 아니었습니다. 이 표현 차이가 별것 아닌 것 같은데, 무엇을 막을지 따질 때 결과가 완전히 다릅니다.
Tip (배운 점)
“X를 막는다”는 표현이 나오면 항상 “무엇을 막고, 무엇은 못 막는가” 를 둘 다 적어보기. 한쪽만 적으면 다른 쪽을 무의식 중에 “안전”으로 가정하게 됨.
4. SameSite Lax의 안전 신화 — “CSRF는 이제 끝”이라는 환상
SameSite=Lax를 처음 알게 됐을 때의 인상은 “이거 하나면 CSRF는 끝”이었습니다. 외부 사이트의 POST 요청에 쿠키가 안 붙으면 CSRF의 핵심 메커니즘 자체가 막히니까요. 한동안 그렇게 믿었습니다.
2편의 SameSite Lax 한계 분석을 쓰려고 자료를 다시 파보니 빈틈이 셋이나 있었습니다:
-
GET은 그냥 통과 — 외부 사이트가
<a href="...">링크로 GET 요청을 만들면 쿠키 자동 첨부. 만약 그 GET 엔드포인트가 상태를 바꾸면 CSRF 성립 -
서브도메인은 SameSite 관점에선 “같은 사이트” — SameSite는 “같은 사이트인지 외부 사이트인지”를 도메인 끝부분(
example.com같은 단위, 공식 용어로 eTLD+1)으로 판단합니다. 그래서admin.example.com과blog.example.com은 호스트가 달라도 SameSite 관점에선 “남”이 아닙니다. 단계별로 풀어보면:1. 사용자가 admin.example.com에 로그인된 상태(브라우저에 admin 세션 쿠키 저장됨)2. 같은 사용자가 다른 탭에서 blog.example.com 페이지를 염(회사 블로그가 워드프레스 같은 외부 CMS라 보안 수준이 낮다고 가정)3. 공격자가 그 blog 페이지에 악성 <script>를 미리 심어둠(= "blog가 XSS에 뚫렸다"의 의미. blog 서버가 해킹된 게 아니라그 페이지에 주입된 코드가 사용자 브라우저에서 실행되는 상태)4. 사용자 브라우저가 blog 페이지를 로드하면서 그 악성 스크립트도 같이 실행5. 악성 스크립트는 사용자 브라우저 안에서 이런 코드를 실행:fetch('https://admin.example.com/api/delete-something')6. 사용자 브라우저가 admin.example.com으로 요청 전송→ 브라우저: "blog와 admin은 같은 사이트(eTLD+1이 동일)이니외부 사이트 요청 아니네" → admin 쿠키 자동 첨부7. admin 서버: "정상 로그인된 사용자가 보낸 인증 요청이네" → 처리→ 결과: 공격자가 사용자 권한으로 admin API를 마음대로 호출핵심은 요청을 보내는 주체가 공격자 서버가 아니라 피해자 사용자의 브라우저라는 점입니다. 사용자 브라우저가 보내는 요청이라 admin 쿠키가 정상적으로 첨부되고, admin 서버가 보기엔 사용자 본인이 한 요청과 구분이 안 됩니다. 결국 보안이 약한 서브도메인 한 곳이 다른 서브도메인의 세션을 통째로 위협하는 통로가 됩니다
-
리다이렉트 체인 —
evil.com → redirect → example.com이면 최종 목적지로 가는 GET은 top-level navigation으로 취급되어 쿠키 첨부
이 셋을 마주한 순간, “SameSite Lax = CSRF 끝” 같은 신화가 깨지면서 동시에 “그럼 우리 프로젝트는 안전한가?”라는 새로운 질문이 시작됐습니다. 다행히 각 빈틈마다 이 프로젝트에서는 별도의 방어선이 있어서 실질적 위협이 안 됐지만(아래 5번 참고), 각 빈틈을 하나씩 따져봤어야 안전을 주장할 수 있었습니다.
Tip (배운 점)
“이거 하나면 X는 끝”이라는 주장을 마주하면 의심 모드로 전환. 보안에서 단일 방어층으로 위협 카테고리 전체를 막는 일은 거의 없음. SameSite도, HttpOnly도, JWT도, 어느 것도 단독으로 “끝”이 아님.
5. CSRF가 성립하지 않음을 논증하기 — 가정의 견고함을 어떻게 보일 것인가
2편의 보안 범위 정의에서 가장 까다로웠던 작업은 “CSRF는 이 프로젝트 구조상 성립하지 않는다”를 논증하는 일이었습니다.
처음에는 이 한 줄로 끝내고 싶었습니다:
“우리는 Authorization 헤더로 인증하니까 CSRF는 안 됨”
하지만 누군가 이 문장을 “정말 그런가?” 하고 파고들기 시작하면, 따로 검증해야 할 가정들이 연쇄적으로 따라옵니다:
- 우리 백엔드의 로그인 필요한 API가 정말로 모두 Authorization 헤더로만 인증하는가? 실수로 쿠키로 인증하는 API가 단 하나라도 섞여 있으면, 그 한 군데에서 CSRF가 가능해지므로 “CSRF 안 됨” 주장 전체가 무너집니다
- 헤더 기반이라는 게 곧 CSRF 안 된다는 의미인가? (그게 왜 그런지 한 줄로 설명할 수 있어야 함 — 외부 사이트에서는 자동으로 헤더가 안 붙으니까)
- refreshToken 쿠키는 어떤가? 쿠키는 자동 첨부되는데?
- 쿠키가 자동 첨부되어도 그 쿠키를 읽는 엔드포인트가 외부 사이트에서 일으킨 요청에 반응하지 않으면 OK
- 그 보장은 어디서 오는가? →
SameSite=Lax+ 갱신·로그아웃 엔드포인트가 POST라는 점 - 이 두 가정이 다 유효한가? → 위 4번에서 본 Lax 빈틈 셋과 비교
이런 검토를 거치고 나서야 2편의 두 줄짜리 주장이 나왔습니다:
- 일반 API 호출은 쿠키가 아닌 Authorization 헤더로 인증 → 외부 사이트에서 요청을 보내도 헤더는 자동으로 안 붙으니까 인증 실패
- refreshToken 쿠키는 갱신·로그아웃 엔드포인트에서만 실제로 읽히며,
SameSite=Lax설정으로 외부 사이트의 POST 요청에는 쿠키가 전송되지 않음
다만 솔직히 짚으면, 이 주장은 무조건 성립하는 게 아니라 조건부로 성립합니다. 두 번째 줄이 4번에서 본 Lax 빈틈 셋과 부딪히는지 따져보면 이렇습니다:
- GET은 그냥 통과 → 갱신·로그아웃이 POST 엔드포인트라 적용 안 됨. 조건: 두 엔드포인트가 POST로 유지될 것
- 서브도메인은 같은 사이트 → 단일 도메인으로 운영되어 신경 쓸 다른 서브도메인이 없음. 조건: 서브도메인이 추가되지 않을 것 (추가된다면
__Host-prefix 같은 추가 방어 필수) - 리다이렉트 체인 → 위 POST 조건에 함께 의존. 외부 사이트가 POST를 자동으로 트리거할 수단이 사실상 없음
즉 정확히 말하면 “CSRF는 성립하지 않는다”가 아니라 “현재 구조(POST 엔드포인트 + 단일 도메인)가 유지되는 동안 성립하지 않는다” 가 진짜 주장입니다. 조건 중 하나라도 깨지면 다시 검증해야 합니다.
두 줄짜리 결과처럼 보이지만, 이 두 줄을 쓰기 전에 가정 하나하나를 직접 검증하는 며칠짜리 작업이 깔려 있습니다. 그리고 그 작업의 결과물은 “완벽한 단언”이 아니라 “조건이 명시된 주장” 이라는 걸 받아들이는 게 보안 주장을 쓰는 사람의 정직한 자세였습니다.
Tip (배운 점)
“X는 성립하지 않는다”는 강한 주장을 적기 전에, 그 주장을 떠받치는 가정들의 목록을 명시적으로 적기. 각 가정이 깨지는 시나리오를 하나씩 검토하고 나서야 주장을 쓸 자격이 생김. 안 적으면 검토자가 결국 적게 됨(그러면 더 오래 걸림).
묶어서
다섯 지점이 다 다른 주제 같지만, 돌이켜보면 같은 패턴입니다:
- 무엇을 막을지 먼저 — 선택지부터 보면 끝없이 비교만 함
- 주장은 가정과 함께 — “X는 안전/위험/불가능”이라는 표현은 항상 가정 위에 서 있음
- 양면을 적기 — “막는다”는 표현은 “못 막는 영역”과 짝지어 적어야 의미가 정확해짐
이 메모를 적어두는 이유는, 다음에 다른 프로젝트에서 비슷한 결정을 마주했을 때 선택지 비교부터 시작하지 않기 위해서입니다. 무엇을 막을지부터 정하고, 가정을 적고, 양면을 적으면, 셋 중 하나가 안 보이는 결정은 의심해볼 것.
함께 읽기:
ari Space