JWT(JSON Web Token) Access & Refresh Token 보안 탈취 방어 아키텍처

JWT(JSON Web Token) Access & Refresh Token 보안 탈취 방어 아키텍처

"프론트엔드에서 로그인 구현할 때 발급받은 JWT 토큰, 그냥 로컬 스토리지(localStorage)에 저장하면 되는 거 아닌가요?"

수많은 주니어 프론트엔드 개발자와 백엔드 개발자가 합작하여 만들어내는 가장 치명적인 보안 사고의 시작점입니다. 로컬 스토리지는 브라우저에 데이터를 영구적으로 저장하기 쉽고 꺼내 쓰기 편하지만, 자바스크립트(JavaScript) 코드로 누구나 접근할 수 있다는 치명적인 약점을 가집니다.

만약 당신의 사이트에 악성 스크립트를 심는 크로스 사이트 스크립팅(XSS) 공격이 단 한 줄이라도 들어온다면, 해커는 유저의 로컬 스토리지에 있는 JWT를 통째로 복사해 가 유저의 계정을 완벽하게 탈취할 수 있습니다. 본 가이드에서는 이 끔찍한 보안 취약점을 원천 차단하는 Access & Refresh Token 분리 및 HTTP Only 쿠키 기반의 무결점 인증 아키텍처를 단호하게 제시합니다.

📌 이 글의 핵심 포인트

  • 로컬 스토리지(localStorage) 저장의 위험성과 XSS(Cross-Site Scripting) 공격의 원리
  • Access Token과 Refresh Token의 수명(Life Cycle) 분리 및 역할 분담
  • httpOnly와 Secure 속성을 활용한 XSS 방어 및 Silent Refresh 파이프라인

1단계: 핵심 원인 분석 - 자바스크립트에 노출된 금고

JWT를 localStorage.setItem('token', jwt) 방식으로 저장하는 것은, 현관문 비밀번호를 포스트잇에 적어서 대문 앞에 붙여놓는 것과 같습니다. 브라우저에 주입된 해커의 악성 자바스크립트 코드는 localStorage.getItem('token') 명령어 단 한 줄로 당신의 토큰을 해커의 서버로 전송합니다.

이를 방어하기 위해서는 브라우저의 저장 공간 중 '자바스크립트로는 절대 읽을 수 없는' 유일한 공간인 httpOnly 속성이 부여된 쿠키(Cookie)를 활용해야 합니다.

2단계: 아키텍처 설계 - Access Token과 Refresh Token의 분리

쿠키를 쓰더라도 완벽한 것은 아닙니다. CSRF(크로스 사이트 요청 위조) 공격의 타겟이 될 수 있기 때문입니다. 따라서 최신 인증 아키텍처는 토큰을 두 개로 쪼개어 각각의 장점만 취합니다.

  1. Access Token (수명 15분~1시간): 실제 API 통신에 사용되는 짧은 수명의 토큰. 프론트엔드 애플리케이션의 로컬 메모리(React의 일반 변수 등)에만 들고 있습니다. 새로고침하면 날아가지만, 해커에게 탈취당해도 짧은 시간 뒤면 무용지물이 됩니다.
  2. Refresh Token (수명 14일): Access Token을 재발급받기 위한 교환권. 백엔드 서버가 프론트엔드로 응답을 줄 때, 자바스크립트가 접근할 수 없는 httpOnly 쿠키에 강제로 구워버립니다.

3단계: 완벽한 해결책 - 백엔드 쿠키 세팅과 Silent Refresh

백엔드(Node.js Express 기준)에서 로그인이 성공했을 때, Refresh Token을 응답 헤더(Set-Cookie)에 담아 httpOnly 옵션을 켜서 내려주는 핵심 코드입니다.


// 백엔드 API: Refresh Token을 httpOnly 쿠키로 발급
res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,       // 자바스크립트 접근(XSS) 원천 차단
    secure: true,         // HTTPS 통신에서만 전송 허용
    sameSite: 'strict',   // CSRF 공격 방어
    maxAge: 14 * 24 * 60 * 60 * 1000 // 14일 뒤 만료
});

// Access Token은 JSON 바디(Body)로 내려주어 프론트엔드 메모리에 저장 유도
res.status(200).json({ accessToken: newAccessToken });

프론트엔드에서는 유저가 브라우저를 새로고침하여 메모리에 있던 Access Token이 날아가면, 백엔드의 /refresh API를 몰래 호출(Silent Refresh)합니다. 이때 프론트엔드 코드는 Refresh Token을 직접 만질 수 없지만, 브라우저가 알아서 httpOnly 쿠키를 백엔드로 함께 실어 보냅니다.


// 프론트엔드 (Axios 인터셉터 예시): Access Token 만료 시 조용히 재발급
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response.status === 401) {
      // 백엔드로 재발급 요청 (브라우저가 httpOnly 쿠키를 자동으로 동봉함)
      const { data } = await axios.post('https://api.mydomain.com/refresh', {}, {
        withCredentials: true // 브라우저 쿠키 전송 허용 옵션 필수
      });
      
      // 발급받은 새 Access Token을 메모리에 다시 저장하고, 실패했던 기존 요청 재시도
      setAccessTokenToMemory(data.accessToken);
      
      // 새로 발급받은 토큰으로 헤더 교체
      error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
      return axios(error.config);
    }
    return Promise.reject(error);
  }
);

💡 아키텍트의 시선 (Insight)

완벽한 보안이란 해커가 뚫지 못하는 강력한 암호를 만드는 것이 아니라, 해커가 암호에 접근할 수 있는 물리적/논리적 경로 자체를 원천 폐쇄해 버리는 아키텍처 설계에 있습니다. localStorage의 달콤한 편리함을 과감히 버리고, httpOnly 쿠키와 메모리 격리 기반의 이중 토큰 시스템을 도입하는 순간 당신의 서비스는 XSS와 CSRF라는 웹 생태계의 양대 위협으로부터 완전히 해방될 것입니다.

댓글

이 블로그의 인기 게시물

Zapier & Make.com 자동화의 덫: 무한 루프(Infinite Loop) 에러 완벽 방어 아키텍처

엑셀 보고서의 종말: 구글 스프레드시트와 루커 스튜디오(Looker Studio)로 실시간 대시보드 구축하기

Docker OOMKilled (Exit Code 137) 에러의 진실: 컨테이너 메모 누수 방어 및 리소스 최적화 아키텍처