2023년 7월 19일
토큰은 어디에 저장해야할까 ?

Intro

우리 프로젝트는 현재 JWT를 활용하고 있다.

JWT(JSON Web Token)에 대해 알아보자.

JWT(JSON Web Token)

: JSON 객체를 사용하여 정보를 안전하게 전송하기 위한 토큰

  • 주로 사용자 인증 및 권한 부여 목적으로 사용된다.

JWT 예시

  • 테스트 : https://jwt.io/ 에서 해볼 수 있다.

  • Encoded

    • 토큰 : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Decoded

    • Header : {"alg": "HS256", "typ": "JWT"}
    • Payload : {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
    • Signature : SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT의 구조

JWT는 Headers, Payload, Signature로 구성되어 있다.

  1. Header : 헤더
    • 해시 알고리즘토큰의 타입을 정의한다.
  2. Payload : 내용
    • 전달하는 데이터(클레임) 를 포함한다.
    • 클레임 : 주체 식별자(sub), 사용자 이름(name), 토큰 발급시간(iat)로 구성되어 있다.
  3. Signature : 서명
    • 유저가 지정하는 비밀 코드이다.
    • 토큰의 무결성과 신뢰성을 보장한다.

위와 같이 헤더, 내용, 서명이 .을 구분자로 연결되어 있다.

JWT의 장점

  1. 자체 포함 : JWT는 필요한 모든 정보를 자체적으로 포함하므로,** 추가적인 서버 호출 없이 인증을 처리**할 수 있다.
  2. 확장성 : 중앙 집중식 서버 상태 저장소 없이 확장 가능한 인증 매커니즘을 제공한다.
  3. 보안 : 서명을 통해 토큰의 무결성을 검증할 수 있다.

JWT의 단점

  1. 토큰 크기: JWT는 암호화된 정보가 아니라 인코딩된 정보를 포함하므로, 토큰 크기가 클 수 있다.
  2. 유출 위험: 비밀 키가 유출되면 모든 토큰이 위조될 위험이 있다.

Access Token과 Refresh Token

  • Access Token : API를 사용하기 위한 인증용 토큰
    • 이 토큰이 만료되면 Access Token이 없는 것과 마찬가지이다.
  • Refresh Token : ACcess Token의 유효기간 연장을 위한 토큰
    • Access Token이 만료되면 Refresh Token을 이용하여 새로운 Access Token을 발급받는다.

토큰의 요휴 여부 판단

Token을 서버로 보내면, 서버는 JWT 라이브러리의 복호화 로직을 사용하여 서명의 Secret을 확인한다. 복호화된 데이터가 서버 DB에 저장된 토큰의 정보와 일치하는지 비교하여 판단한다.

고민

토큰을 어디에 저장해야 할까?

토큰은 어디에 저장해야 안전할까, 그리고 좋은 방법일까?

이번 프로젝트는 모바일 앱에서 웹뷰를 띄우는 형태이다. 이 경우에도 Refresh Token과 Access Token을 모두 사용하여 토큰을 관리하여야 할까? 그에 앞서서, 다른 프로젝트는 어떻게 했을까?

토큰 관리 리서치

1. 트레바리 : 쿠키에 토큰 하나만 저장한다.

웹뷰로 기반인 트레바리 서비스를 먼저 살펴보자.

cookie에 토큰값을 저장하고 있다. 작성일 기준 7월 3일이니, 토큰의 만료 기간은 1달임을 확인할 수 있다.

또한, cookie를 제거 후 새로고침을 하면 로그인이 풀리는 것을 확인할 수 있다. 이는 쿠키에 토큰을 저장하고 있기 때문이다.

2. 무신사 : 쿠키에 토큰 하나만 저장한다.

두 번째로 최근에 PC에서도 웹뷰로 통일한 무신사 서비스를 살펴보았다.

무신사 또한 쿠키에 저장을 하고 있다. 대신, 만료 시간에 '세션'이라 적혀 있는 걸로 보아, 세션 쿠키인 것을 확인할 수 있다.

  • 토큰 디코딩 해보기
  1. Header에는 해시 알고리즘토큰의 타입이 적혀있다. 처음 예시에 있던 Header와 동일한 것을 확인할 수 있다.
    • eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  2. 그리고, 중간값인 Payload에는 나에 대한 정보가 담겨있다. 내 닉네임, 성별, 나이, 주문 건수 등등
  3. 마지막 Signature에는 서버 측에서 지정한 비밀 코드가 담겨있다.

쿠키 저장소를 보면, Expires에 ‘세션’이라 적혀있는 것을 확인할 수 있다. 이 말은 즉슨, 이 쿠키는 ‘세션 쿠키’라는 것이다. 일반적인 쿠키는 expires의 시간이 지나면 제거가 되지만 세션 쿠키는 세션이 종료되면 제거가 된다는 점에서 차이가 있다.

그래서 무신사 사이트는 세션이 종료되면, 즉 브라우저가 종료되면 해당 토크니 사라지며 새로 로그인을 해야 한다.

세션 쿠키 vs 지속 쿠키
  1. 세션 쿠키 : 세션이 종료되면 사라진다. 즉, 모든 브라우저를 종료하면 사라진다.
  2. 지속 쿠키 : expires에 명시된 기간이 종료되면 사라진다.

쿠키를 저장할 때, 만료기간을 설정하면 해당 만료기간을 가진 지속 쿠키가 되고, 설정하지 않으면 세션 쿠키가 된다.

관련 내용 : 브라우저 저장소 (localStorage, SessionStorage, Cookie)

그렇다면, 다른 사이드 프로젝트에서는 어떻게 토큰을 관리하고 있을까?

3. 디프만 - 아맞다 : 로컬 스토리지에 Access Token과 Refresh Token을 저장한다.

디프만의 아맞다에서는 토큰을 localStorage에 저장하였다. Access Token과 Refresh Token을 따로 구분하지 않았다.

대신에, 사용자를 구분하는 userToken과 firebase 알림 토큰인 fcmToken을 또 사용하였다.

4. wordbe(티스토리 블로그) : asyncStorage에 토큰 하나만 저장한다.

링크에서는 React Native로 구현하였는데, asyncStorage를 사용하여 디바이스의 하드드라이브에 토큰을 저장하였다.

이 방법을 사용한다면, 로그인 > 웹에서 앱으로 토큰 전달 > asyncStorage에 저장 > 앱에서 웹으로 필요할 때마다 토큰 저장. 이 방식으로 구현 가능하지 않을까?

5. 디프만 - 영감탱 : 로컬 스토리지에 Access Token과 Refresh Token을 저장한다.

디프만 - 영감탱에서는 Refresh Token과 Access Token을 모두 관리하였다.

  1. Refresh Token : local storage에 저장 & react native로 전송 ⇒ 다시 접속했을 때 활용
  2. Access Token : local storage에 저장 & axios instance에 headers로 설정 ⇒ api 쏠 때 활용

세부적인 구현 로직은 다음과 같다.

  1. 모든 페이지에 공통적으로 적용되는 _app.tsx 혹은 layout.tsx페이지를 UserProvider로 감싼다.
  2. UserProvider에서, localStorage의 토큰을 체크한다.
  3. Access Token이 없거나 만료되었다면 서버에 reissue api를 날려 재발급 받는다.
  4. Refresh Token이 없거나 잘못되었다면 로그인 페이지로 redirect시킨다.
localStorage나 asyncStroge에 토큰을 저장했을 때의 단점

위와 같은 방식으로 localStorage 혹은 asyncStroage를 이용하면 서버에서 먼저 데이터를 받아와서 페이지를 구성해서 넘겨주는 SSR 방식을 활용하지 못한다.

위와같은 Next.js의 SSR, SSG같은 장점을 활용하지 못한다는 치명적인 단점이 존재한다. 서버에서 먼저 데이터를 fetching한다는 것은 서버에서 토큰에 접근할 수 있어야 한다는 것을 의미한다. 서버에 데이터를 저장하고, 접근할 수 있는 방법으로는 쿠키가 있다.

쿠키에 대해 알아보자.

쿠키

: 사용자의 컴퓨터에 저장하는 작은 데이터 조각이다.

서버에서 쿠키를 클라이언트에 저장하는 방법

  • 서버가 클라이언트에 응답할 때 쿠키에 저장하고자 하는 정보를 Header의 Set-Cookie로 함께 전달된다. Set-Cookie: key=value; path=/;

  • 클라이언트는 서버로 전송하는 모든 요청에, 현재 브라우저에 저장된 모든 쿠키를 Header의 Cookie로 전달한다. Cookie: key=value; key2=value2;

클라이언트에서 쿠키를 저장하는 방법

  1. window 객체의 document.cookie에 저장한다. document.cookie = 'key=value; path=/;'

쿠키의 용도

여러 페이지를 이동할 때마다 로그인을 하지 않아도 사용자 정보를 유지할 수 있게 해주는 것이 쿠키이다.

  1. ID 저장
  2. 로그인 상태 유지
  3. 하루동안 다시 보지 않기
  4. 최근 검색한 상품들을 광고에서 추천
  5. 쇼핑몰 장바구니 기능
쿠키 vs 로컬 스토리지

쿠키

  • 4KB의 용량이 작은 매우 작은 양의 데이터를 저장할 때 사용한다.
  • 문자열만 저장 가능하다.
  • HTTP 요청 시 서버가 접근할 수 있다.
  • XSS로부터 안전하다 : 서버에서 쿠키의 httpOnly옵션을 설정하면 JS에서 쿠키에 접근 자체가 불가능하다.
    • XSS : Cross-Site Scripting, 악의적인 스크립트를 삽입하여 사용자의 정보를 탈취하는 공격
  • CSRF로부터 위험하다 : 공격자가 사용자의 HTTP 요청을 가로챈 뒤 악의적인 요청을 보낼 수 있다.
    • CSRF : Cross-Site Request Forgery, 사용자가 의도하지 않은 요청을 보내는 공격

로컬 스토리지

  • 5MB의 용량이 큰 양의 데이터를 저장할 때 사용한다.
  • 데이터를 영구 저장한다.
  • 문자열만 저장 가능하다.
  • XSS로부터 위험하다.
  • CSRF로부터 안전하다.

정리하면, 쿠키는 악성적인 코드를 심는 XSS로부터 안전하고, 사용자가 HTTP 요청을 가로챈 뒤 악의적인 요청을 보낼 수 있는 CSRF로부터 위험하다. 그러므로 Refresh Token을 저장하는데 적합하다. 왜냐하면, Refreh Token만으로는 사용자의 정보를 탈취할 수 없기 때문이다. Refresh Token으로부터 Access Token을 발급받아야만 사용자의 정보를 탈취할 수 있다.

결론

  1. 서버에서 브라우저에 저장된 값에 접근하기 위해서는 cookie를 사용해야 한다.
  2. cookie에 저장된 값은 XSS공격으로부터 안전하다.
  3. 리서치 결과, 다른 사이트에서도 쿠키에 토큰을 저장하는 경우가 많았다.

구현

1. 쿠키에 서버로부터 전달받은 토큰을 저장한다.

회원가입을 하거나 로그인을 했을 때, 서버로부터 Access Token과 Refresh Token, 그리고 userId를 받는다. 이를 쿠키에 저장한다.

  for (const [cookieKey, cookieValue] of generateCookiesKeyValues({
    accessToken,
    refreshToken,
    userId,
  })) {
    document.cookie = `${cookieKey}=${cookieValue}; path=/;`; // document.cookie에 저장한다.
  }

2. 쿠키에서 토큰을 꺼내 사용한다.

axios를 이용하여 서버에 HTTP 요청을 할 때, headers에 Access Token을 담아서 보내야 한다. 이를 위해 axios.interceptors.request.use를 이용하여 api요청할 때마다 headers 설정을 해주었다.

  1. 브라우저에 저장된 cookie값을 꺼낸다.

    const getAuthTokensByCookie = (cookieString: string): Partial<CookieKeyType> => {
      const auth: Partial<CookieKeyType> = {}; 
      for (const cookie of cookieString.split('; ')) { // 쿠키는 ;로 구분되어 있다.
        const [key, value] = cookie.split('='); // key와 value는 =로 구분되어 있다.
        if (key === AUTH_COOKIE_KEYS.accessToken) { // key가 accessToken이면
          auth.accessToken = value;
        } else if (key === AUTH_COOKIE_KEYS.refreshToken) { // key가 refreshToken이면
          auth.refreshToken = value;
        } else if (key === AUTH_COOKIE_KEYS.userId) { // key가 userId이면
          auth.userId = +value;
        }
      }
      return auth;
    };
    const auth = getAuthTokensByCookie(document.cookie); // document.cookie에서 토큰을 꺼낸다.
  2. axios.interceptors.request.use를 이용하여 headers에 Access Token을 설정한다.

    export const onRequestClient = async (config: InternalAxiosRequestConfig) => {
      try {
        const auth = getAuthTokensByCookie(document.cookie); // document.cookie에서 토큰을 꺼낸다.
     
        if (auth.accessToken) {
          config.headers['X-AUTH-TOKEN'] = auth.accessToken; // headers에 Access Token을 설정한다.
        }
        return config;
      } catch (error) {
        return Promise.reject(error);
      }
    };
    privateApi.interceptors.request.use(onRequestClient, onRequestError); // axios.interceptors.request.use를 이용하여 headers에 Access Token을 설정한다.

마무리하며

이번 글에서는 JWT를 활용한 토큰 기반 인증의 구조와 장단점, 그리고 Access Token과 Refresh Token의 역할에 대해 살펴보았다. 또한, 웹 및 모바일 앱 프로젝트에서 토큰을 어디에 저장할지에 대한 고민을 기반으로, 다양한 프로젝트의 사례를 통해 토큰 관리 방식을 리서치하고 비교해보았다.

핵심 요약

  1. JWT의 구조와 장단점:

    • JWT는 Header, Payload, Signature로 구성되며, 자체 포함성과 확장성, 보안성을 제공한다.
    • 하지만 토큰 크기가 크고, 비밀 키 유출 시 위험이 있다는 단점이 있다.
  2. Access Token과 Refresh Token:

    • Access Token은 API 사용을 위한 인증 토큰이며, Refresh Token은 Access Token의 유효기간 연장을 위한 토큰이다.
    • 서버는 JWT 라이브러리를 통해 토큰의 유효성을 확인한다.
  3. 토큰 저장 위치:

    • 웹 프로젝트에서는 보안을 고려하여 Refresh Token을 쿠키에 저장하고, Access Token을 localStorage에 저장하는 방식을 사용했다.
    • 모바일 앱 프로젝트에서는 웹뷰를 활용한 경우, 토큰을 쿠키나 localStorage, 또는 asyncStorage에 저장하는 다양한 방법을 사용했다.
  4. 리서치 결과:

    • 트레바리와 무신사는 쿠키에 토큰을 저장했다.
    • 디프만 - 아맞다와 디프만 - 영감탱은 로컬 스토리지에 토큰을 저장했다.
    • Wordbe는 asyncStorage를 활용했다.

결론

최종적으로, 서버에서 브라우저에 저장된 값에 접근하기 위해서는 쿠키를 사용하는 것이 유리하며 쿠키에 저장된 값은 XSS 공격으로부터 안전하다는 장점을 가지고 있다. 그러나 CSRF 공격에 대비하기 위해 추가적인 보안 조치가 필요하다. 로컬 스토리지는 더 많은 데이터를 저장할 수 있지만, XSS 공격에 취약하다는 단점이 있다.

구현 예시

회원가입이나 로그인 시, 서버로부터 받은 Access Token과 Refresh Token, userId를 쿠키에 저장하고, axios를 통해 서버에 요청할 때 쿠키에서 토큰을 꺼내 headers에 설정하는 방법을 구현했다. 이를 통해 토큰 관리의 보안성과 편의성을 모두 확보할 수 있었다.

앞으로의 방향

보안성, 그리고 서버에서 접근이 가능한 지를 고려하며 토큰을 저장하는 방법에 대해 고민해보았다. 브라우저 스토리지에는 쿠키, 로컬 스토리지, 세션 스토리지 등 다양한 방법이 있으며 각 장단점이 존재한다. 앞으로 개발을 할 때 이러한 부분을 고려하며 개발을 해야겠다고 생각했다.