Logo ari Space
인증 아키텍처에서 가장 헷갈렸던 다섯 지점 — 토큰 위치, 비대칭 구조, HttpOnly, SameSite Lax, CSRF 논증

인증 아키텍처에서 가장 헷갈렸던 다섯 지점 — 토큰 위치, 비대칭 구조, HttpOnly, SameSite Lax, CSRF 논증

April 15, 2026
7 min read
Table of Contents
index

1편2편을 정리한 글이지만, 완성된 결론만 보여주다 보니 그 결론에 도달할 때까지 가장 시간을 많이 쓴 지점들이 빠졌습니다. 이 글은 그 사이드 노트입니다. “이게 왜 이렇게 되는 거지” 또는 “이 결정이 맞나” 하며 며칠씩 머물렀던 다섯 지점을 따로 뽑았습니다.

회고(3편)가 “다시 한다면 어떻게 할지”라면, 이 글은 “그때 어디서 막혔는지” 입니다.


1. 토큰 저장 위치 결정 — 세 선택지가 머릿속에 잘 안 그려졌던 이유

1편의 세 가지 전략 비교는 결론만 보면 “장단점 정리해서 골랐다”는 깔끔한 의사결정처럼 보이지만, 실제로는 셋 다 추상적으로만 와닿아서 한참을 헤맸습니다.

처음 머리에 떠오른 질문들은 이런 것들이었습니다:

  • localStorage가 위험하다는 건 들어봤는데, 얼마나 위험한가? 7일짜리 refreshToken이 노출되면 구체적으로 무슨 일이 일어나나?
  • 서버 세션이 “전통적이고 안전한 정공법”이라는데, 그게 왜 이 프로젝트엔 과한가? “백엔드 수정 비용”이라는 말이 너무 추상적이다.
  • HttpOnly 하이브리드가 “절충안”이라는데, 정확히 뭘 절충하는 건가? 보안과 무엇을 맞바꾸는가?

이 셋이 답이 안 나오는 진짜 이유는 무엇을 막을지 먼저 정하지 않았기 때문이었습니다. 셋을 추상적으로 비교하면 영원히 답이 안 나옵니다. “공격자가 어떤 경로로 무엇을 노릴 수 있는가”를 먼저 그려야 각 선택지가 어디서 다른지가 보입니다.

이 프로젝트에서 무엇을 막을지 명시적으로 적어보니:

  • 막을 것 (현실적 위협): XSS를 통한 토큰 탈취 (사용자가 npm 의존성 어딘가에서 악성 코드를 실행시키는 시나리오)
  • 안 막을 것 (범위 밖): CSRF (Authorization 헤더 기반이라 외부 사이트가 인증된 요청을 만들 수 없음), MITM (HTTPS 강제)

이렇게 정리하고 나니 세 선택지가 갑자기 명확해졌습니다:

  • 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 대비 결정적인 차이가 있긴 있는데, 그게 정확히 무엇인지 한 줄로 안 나왔습니다. 결국 정리한 건:

localStorageHttpOnly Cookie
토큰 값 자체외부 서버로 유출 가능외부로 유출 불가
공격 가능 조건토큰만 있으면 어디서든XSS 활성 동안, 피해자 브라우저에서만
사용자가 페이지 닫으면공격자 여전히 7일 사용공격 종료

즉 HttpOnly의 진짜 가치는 **“외부 유출 차단”**이지 **“사용 차단”**이 아니었습니다. 이 표현 차이가 별것 아닌 것 같은데, 무엇을 막을지 따질 때 결과가 완전히 다릅니다.

Tip (배운 점)

“X를 막는다”는 표현이 나오면 항상 “무엇을 막고, 무엇은 못 막는가” 를 둘 다 적어보기. 한쪽만 적으면 다른 쪽을 무의식 중에 “안전”으로 가정하게 됨.


4. SameSite Lax의 안전 신화 — “CSRF는 이제 끝”이라는 환상

SameSite=Lax를 처음 알게 됐을 때의 인상은 “이거 하나면 CSRF는 끝”이었습니다. 외부 사이트의 POST 요청에 쿠키가 안 붙으면 CSRF의 핵심 메커니즘 자체가 막히니까요. 한동안 그렇게 믿었습니다.

2편의 SameSite Lax 한계 분석을 쓰려고 자료를 다시 파보니 빈틈이 셋이나 있었습니다:

  1. GET은 그냥 통과 — 외부 사이트가 <a href="..."> 링크로 GET 요청을 만들면 쿠키 자동 첨부. 만약 그 GET 엔드포인트가 상태를 바꾸면 CSRF 성립
  2. 서브도메인은 같은 사이트admin.example.comblog.example.com은 SameSite 관점에서 같은 사이트. blog가 뚫리면 admin으로 쿠키 자동 첨부
  3. 리다이렉트 체인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 헤더로 인증한다는 게 정말 모든 보호 엔드포인트에 해당하는가? (예외 한 개라도 있으면 단언이 무너짐)
  • 헤더 기반이라는 게 곧 CSRF 안 된다는 의미인가? (그게 왜 그런지 한 줄로 설명할 수 있어야 함 — 외부 사이트에서는 자동으로 헤더가 안 붙으니까)
  • refreshToken 쿠키는 어떤가? 쿠키는 자동 첨부되는데?
    • 쿠키가 자동 첨부되어도 그 쿠키를 읽는 엔드포인트가 외부 사이트에서 일으킨 요청에 반응하지 않으면 OK
    • 그 보장은 어디서 오는가? → SameSite=Lax + 갱신·로그아웃 엔드포인트가 POST라는 점
    • 이 두 가정이 다 유효한가? → 위 4번에서 본 Lax 빈틈 셋과 비교

이런 검토를 거치고 나서야 2편의 두 줄짜리 단언이 나왔습니다:

  • 일반 API 호출은 쿠키가 아닌 Authorization 헤더로 인증 → 외부 사이트에서 요청을 보내도 헤더는 자동으로 안 붙으니까 인증 실패
  • refreshToken 쿠키는 갱신·로그아웃 엔드포인트에서만 실제로 읽히며, SameSite=Lax 설정으로 외부 사이트의 POST 요청에는 쿠키가 전송되지 않음

겉으로는 두 줄이지만, 뒤에 검토 안 된 가정이 없다는 확인 작업이 며칠 깔려 있습니다. 보안 단언은 항상 이런 식이라는 걸 받아들이고 나니, 이후 비슷한 논증을 적을 때 시간이 줄었습니다.

Tip (배운 점)

“X는 성립하지 않는다”는 강한 단언을 적기 전에, 그 단언을 떠받치는 가정들의 목록을 명시적으로 적기. 각 가정이 깨지는 시나리오를 하나씩 검토하고 나서야 단언을 쓸 자격이 생김. 안 적으면 검토자가 결국 적게 됨(그러면 더 오래 걸림).


묶어서

다섯 지점이 다 다른 주제 같지만, 돌이켜보면 같은 패턴입니다:

  1. 무엇을 막을지 먼저 — 선택지부터 보면 끝없이 비교만 함
  2. 단언은 가정과 함께 — “X는 안전/위험/불가능”이라는 표현은 항상 가정 위에 서 있음
  3. 양면을 적기 — “막는다”는 표현은 “못 막는 영역”과 짝지어 적어야 의미가 정확해짐

이 메모를 적어두는 이유는, 다음에 다른 프로젝트에서 비슷한 결정을 마주했을 때 선택지 비교부터 시작하지 않기 위해서입니다. 무엇을 막을지부터 정하고, 가정을 적고, 양면을 적으면, 셋 중 하나가 안 보이는 결정은 의심해볼 것.


함께 읽기: