토큰을 JS 인메모리에 저장하면 도대체 뭐가 좋은걸까?

민호·2025년 4월 16일
65

deepdive

목록 보기
3/9
post-thumbnail



이번에 디프만 16기를 하게 되면서 우리 팀의 로그인 기능을 담당하게 되었다.

매번 사이드 프로젝트를 진행할 때 마다 로그인 기능을 맡게 됐었는데, 개인적으로 단순 UI를 넘어서 비즈니스적 관점에서 전체적인 유저 플로우에 많은 고민을 할 수 있다는 점에 매우 큰 매력을 느꼈기 때문이다.

또한 디프만에서 진행했던 1, 2차 유저 테스트 결과에서, 개인 정보가 아카이빙 된다는 점에 대해 우려를 표현한 분들도 매우 많았다.

결국 단순한 '시도'를 넘어, 어떻게든 '유저의 입장에서 필요한 부분들을 나만의 기술적 수단으로 해결해 보는 도전'을 해보고 싶었다.



모든 기술에는 트레이드 오프가 있듯이, 본 글은 일방적으로 특정 방법론이 기술적으로 우위에 있다는 것을 증명하는 글은 아닙니다.
단지 하나의 기술적 방향으로써 제가 도전했고, 이 과정에서 생각했던 내용을 회고하는 것입니다.






도대체 토큰을 어디에 보관해야 할까?


우리 팀은 인증 수단으로 JWT토큰 방식을 사용했다.

솔직히 말하면, 세션ID JWT를 고민하며 트레이드 오프를 깊게 고민하지는 않았다.

  • 왜냐하면 애초에 “세션 ID는 서버의 자원을 사용해야 하기 때문에 서버에 부담이....” “세션 ID는 확장성이 떨어지기 때문에....” 와 같은 이론적인 단점은 우리팀에 큰 의미가 없었다

  • 그냥 우리팀 모두가 세션ID에 대한 경험이 없었고 JWT에 많은 경험이 있었기에 굳이 러닝커브를 동반하고 싶지는 않았다. 어떻게 보면 생산적인 측면이 고려된 것이다.



어쨌든 JWT를 사용하는 입장에서, 기술적인 도전으로 고려했던 점은 “JWT의 취약점을 어떻게 보완할 수 있을까?“ 였다.

그 방법으로 내가 주목했던 것은 "accessToken을 어디에 보관해야 할까?” 이다





매우 간편하며 가장 보편적인 방법이다.

만약 기술적 도전이라는 명분이 없었다면 뒤도 안 돌아보고 쿠키만 사용했을 것이다.

  • 쿠키는 상대적으로 휘발성이 높지 않기에 보관에 큰 신경을 쓰지 않아도 되고

  • 브라우저의 모든 요청에 쿠키가 동봉되기 때문에 간편하게 사용할 수 있다

  • 그리고 samesite, httponly, secure 등의 옵션으로 기본적인 보안 역시 챙길 수 있다.


❗ 그렇지만 Cookie에 토큰을 보관하면, CSRF공격에 약간의 취약점이 존재한다.

CSRF가 뭔지 간략하게 말하자면

토큰 값을 직접 탈취하는 것은 아니고, 로그인 된 상태로 특정 위험한 동작을 하게끔 만드는 것이다.

  1. 우리가 bank.com로그인 돼있다고 해보자. 여기에 우리의 인증 쿠키가 저장되어 있다.

  2. 공격자는 피해자에게 그럴듯한 사이트 링크 attackersite.com 를 전송하고 누르게 한다.

    이 문서에는 bank.com에 HTTP 요청을 보내는 코드가 있고, 이미지 태그같은 보이지 않는 방법을 이용해 피해자가 알아차리지 못하게 만든다

  3. 해당 요청은 bank.com로의 요청이며, 그 요청에는 이미 쿠키가 포함되어 있으므로
    로그인된 상태로 파악하기 때문에 유효한 요청이라 판단한다.

  4. 이는 결국 공격자가 원하는 동작을 수행하게 되는 것이다.



쿠키를 사용하게 될 경우, 브라우저는 기본적으로 모든 요청에 쿠키를 동봉하게 된다.

리액트 애플리케이션 내부에서 호출한 API호출 뿐만 아니라 img 태그의 요청, form 태그의 요청 등 모든 요청에 포함된다.

즉, 이러한 브라우저의 특성을 이용한 것이 CSRF 공격이다.






🤔 "애초에 cross-origin인 경우 쿠키가 안 보내지는거 아닌가?"

반은 맞고 반은 틀린 말이다.

애초에 다른 origin에서 쿠키를 읽는 건 불가능하다.

ex) bank.com 의 쿠키를 attackersite.com 에서 직접 읽어내는 것은 불가능하다.


❗ 그러나, 다른 origin간에 요청을 보낼 때 쿠키가 포함되는 건 samesite옵션에 따라 나뉘어진다.

😮 attackersite.combank.com 로 요청을 보낼때, bank.com 도메인에서 저장됐던 쿠키를 전송할 수 있는 경우가 있는 것이다.

이런 빈 틈 때문에 CSRF가 발생하는 것이다. 그러므로 각 samesite옵션에 따라 어떤 경우가 가능한지 한번 알아보자.



  • SameSite=None

    브라우저는 요청의 대상 origin에 해당하는 쿠키가 있다면 자동으로 포함한다.
    그냥 다 된다고 보면 된다.

    TMI이긴 하지만, 크롬은 SameSite=None을 굉장히 싫어하기에, 이 옵션은 Secure=true옵션이 필수적이다.
    물론 localhost는 예외다.

  • SameSite=Lax

    ❗ 상황에 따라 가능하다

    여기서 말하는 상황이란, Top Level Navigation(웹 페이지 이동)을 의미한다

    앞서 예시로 들었던 <img src="http://bank.com/transfer?amount=1000&to=attacker_account"> 의 경우 Top Level Navigation이 아니므로 이때는 요청에 쿠키가 동봉되지 않는다.

    그러나, 아래의 경우들에서는 제한적으로 요청시 쿠키가 동봉된다.

    • <a>태그의 링크

    • window.location.replace 등으로 인해 이뤄지는 이동

    • 302 리다이렉트를 이용한 이동



    “브라우저가 다른 사용자의 블로그에 대해 amazing-cat.png를 요청하면 사이트에서 쿠키를 전송하지 않습니다. 하지만 독자가 사이트의 cat.html 링크를 클릭하면 요청에 쿠키가 포함됩니다.”



    결국 위와 같은 상황에서 브라우저는 요청의 대상 origin(여기선 bank.com)에 해당하는 쿠키가 있다면 자동으로 포함하게 된다.

    프리로드 탐색(Preloading)이나 GET요청에는 제한적으로 된다고 하는 블로그도 있는데 정확한 정보는 아닌것 같아서 생략한다



  • SameSite=Strict

    안 된다.
    이름값 한다고 보면 된다. 오직 퍼스트 파티 쿠키만 가능하다.








🤔 "그럼 SameSite=Strict 딸-각 하면 되는거 아닌가?"

이론적으론 맞지만 실질적으로는 힘들 수 있다.

SameSite=Strict는 CSRF를 완전히 방지할 수 있지만, 사용자 경험에 문제가 생긴다.

사용자를 추적하고 식별하는 목적으로 현대 웹에서는 생각 이상으로 서드 파티 쿠키가 많이 사용된다.

  1. 광고 추적 및 타겟팅

흔히들 마주치는 사용자 맞춤 광고인 Google Ads는 서드 파티 쿠키 형태로 사용한다.

  1. 웹 분석 및 사용자 행동 분석

Google Analytics와 같은 분석 플랫폼 역시 서드 파티 쿠키를 활용한다

그래서 많은 사이트가 SameSite=Lax를 사용하지만, 결국 이로 인해 CSRF 공격의 틈이 발생할 수 있는 것이다.





2. JS 인메모리

결국 위와 같은 이슈로 accessToken을 JS 인메모리에 저장하기로 했다.

거의 바이블 처럼 여겨지는 유명 블로그 에서 언급한 방식처럼 현재 프로젝트 사용중인 axios 인스턴스의 헤더에 넣어주는 것이다.





🤔 그렇다면 어떻게 이 방법이 CSRF를 방지할 수 있는 것일까?

이를 이해하기 위해 ‘네트워크 요청’의 주체를 구분해야 한다.



앞서 CSRF에 뚫릴 수 있다고 말한 방식의 요청을 진행하게 된다면

  • 여기서 img와 a태그로 인해 각 요청을 보내는 주체는 브라우저이다.


그러나 아래의 설정을 통해서 진행되는 요청은

  • 내 React 애플리케이션 내부에서 axios라는 라이브러리를 통해 요청하는 경우에만 적용된다.
    흔히 알고있는, 우리 서비스 내부에서 백엔드 API로 JSON을 요청하는 경우에만 동작하는 것이다.



이게 무슨 말이냐 하면 <img><form>, <a> 의 기본 요청은 axios라는 라이브러리로부터 발생하는 요청이 아니라, 브라우저단에서 자체적으로 요청하는 것이다.

  • 이런 요청은 내 React 애플리케이션의 axios요청과 아무 관련이 없다.

  • 그냥 브라우저가 자체적으로 “이미지 가져와야 하니까 HTTP 요청 보내자!” 하고 실행하는 것이다.

    그러므로 애초에 axios의 헤더에 토큰을 넣어서 보내는 동작 자체와 관련이 없다.

    ❗ 즉, axios header에 등록된 토큰은 자동으로 보내지지 않는다는 것이다.




그리고, 브라우저는 Authorization 헤더를 절대 자동으로 붙이지 않는다.

이유는 보안 정책(CORS + SOP + 헤더 제한 정책) 때문이다

→ 브라우저는 기본적으로 외부 origin 요청에서 민감한 헤더를 붙이지 않는다.

→ 심지어 같은 origin 이라 해도 애초에 Authorization 헤더는 자동으로 붙지 않는다.

→ 개발자가 직접 React 애플리케이션의 axios 요청에 대해 명시적으로 달아줘야만 한다.

결국 위와 같은 이유로 CSRF 공격자가 탈취하거나 전송할 수 있는 방법이 없는 것이다.




🤔 "근데 이 방식을 사용하게 되면 토큰을 다루기가 어렵지 않나?"


❗맞는 말이다... 이는 명확한 단점으로 작용하고 충분히 트레이드 오프 측면에서 고려될 수 있다고 생각한다.

사실 나도 구현하면서 “아 그냥 쿠키 쓸까“ 라고 생각한게 한두번이 아니었다.

왜냐하면 JS 메모리 자체가 휘발성이 굉장히 높기 때문이다.

  • 클라이언트 사이드 라우팅의 경우에는 JS 메모리가 그대로 유지가 되지만

  • 서버 사이드 라우팅 (새로고침, URL 직접 입력 등)의 경우에는 JS 메모리가 전부 휘발된다.

유저가 새로고침을 하거나 URL 직접 입력하는 등의 행위는 충분히 많이 발생하는 유스케이스였기에 이런 상황이 발생할 때마다 직접 재발급을 진행해줘야 한다.


처음에는 “그냥 재발급 로직만 추가하면 되는거 아닌가?” 하고 만만하게 봤었는데, 이게 생각보다 서비스의 특성에 따라 인증 로직 간의 의존성이 늘어날수록 그 복잡도가 기하급수적으로 올라간다.


나같은 경우

  • 모든 페이지에서 유저 인증 기반 로직이 발생하고
  • 백그라운드에서 자동으로 재발급이 돼야 했으며
  • 특수한 상황에서는 옵져버 패으로 특정 API를 지속적으로 polling까지 해야 하는 상황이었다.

즉 이런 상황에 높은 JS 메모리의 휘발성까지 고려해야 했었다.

아래 사진은 내가 실제 로그인 싸이클을 구현하면서 PR에 올렸던 사진이다.



뭐가 됐든, accessToken을 JS 메모리에 보관하는 것과 이로 인해 발생하는 기술적 복잡도는 필연적인 상관관계를 가지게 되는 것이다.

어떻게 보먼 오히려 휘발성이 높다는 특징으로 인해 accessToken의 주기가 매우 짧아 질 수 밖에 없고, 이게 보안 관점에서는 긍정적 요소가 될 수 있다고 생각되긴 한다.





🤔 refreshToken은 어디에 보관할까?

Cookie에 넣어야 한다.

그게 아니면 답이 없다. accessToken이 휘발성이 높아서 그때마다 재발급을 해줘야 하는데, refreshToken마저 JS 인메모리에 넣어버리면 재발급을 아예 할 수가 없어지기 때문이다.

최소한 refreshToken만큼은 재발급을 위해서 서버 사이드 라우팅과 독립적으로 영속성을 유지해야만 한다.




🤔 "어 그럼 CSRF로 쿠키에 있는 refreshToken이 탈취당하면 큰일나는거 아닌가?"

이 상황은 별로 큰 문제로 삼지 않아도 된다(고 생각한다)

  1. 애초에 refreshToken을 탈취해서 accessToken을 재발급 받아도, 그걸 사용하는 헤더는 현재 리액트 애플리케이션의 axios요청에만 국한된다. 즉, CSRF같은 외부 도메인을 통한 요청이 아닌 것이다.

  2. 위에서 언급했었던 accessToken의 높은 휘발성 때문에 사용 환경이 매우 제한적일 것이다.

  3. 현재 서버에서는 RTR(Refresh Token Rotation) 방식을 사용하고 있기에 어차피 refreshToken을 탈취했다 하더라도 1회성에 불과하다.




+ 번외) 왜 XSS는 고려하지 않았을까?

흔히들 웹 개발하면 가장 많이 마주치는 보안 관련 상식이 CSRF와 더불어 XSS가 있다. 그러나 이 글에서는CSRF만 언급할뿐, XSS는 별도로 고려하지 않았다. 그 이유는 다음과 같다.

무책임하게 들릴 수 있지만, XSS방식은 근본적으로 토큰 저장소의 선택만으로는 막을 수 없기 때문이다.

XSS 자체가 JS 애플리케이션 내부에 스크립트를 주입해서 특정 값을 가져오는 것인데, 처음에는 아래와 같이 이해를 했었다.

  • 토큰을 JS 메모리에 보관하게 되면 XSS에 취약하다.
  • 그렇지만 Cookie는 httpOnly을 사용하게 되면 애초에 JS로 접근이 불가능하니까 XSS로부터 안전하다


그러나 Cookie역시 XSS로부터 안전한 것은 아니라는 생각이 들었다.


httpOnly 쿠키에 토큰을 보관하면 직접 접근은 못 하겠지만, XSS 취약점을 노려 API를 요청하면 httpOnly 쿠키에 담긴 값들도 함께 보내지기 때문에 유저인 척 정보를 빼올 수 있기 때문이다.

즉, XSS는 토큰 저장소의 문제가 아니다. 클라이언트와 서버에서 입력 검증, CSP, escape 처리 등의 추가적인 수단을 사용해서 방어 해야 한다.






어쨌든 이 과정을 거치며 결국 로그인 싸이클을 구축해냈다.

글로 적으니 간단해 보이긴 했지만, 처음부터 쿠키의 옵션에 의존 되는 부분과 CSRF와 XSS등 많은 것을 공부할 수 있는 계기가 되었다.

profile
Magnificent Tree.

4개의 댓글

comment-user-thumbnail
2025년 4월 17일

토큰 저장 방식을 많이 고민한게 느껴지네요. 저도 제 일을 할 때 많이 고민하곤 한답니다. -Hansi Flick

1개의 답글
comment-user-thumbnail
2025년 4월 25일

결국 SNL 공인인증서 편이 떠오르네요... 보안성과 사용자의 편의는 반비례한다... 이 간극을 좁히는게 개발자의 역사적 임무라 할 수 있겠네요... 고민하는 자가 항상 답을 찾을겁니다 다들 파이팅!

1개의 답글