내가 현 회사에서 JWT 인증 보안을 위해 고민, 개선한 내용을 기록해 보겠다.
기존 회사의 서비스는 JWT로 인증 처리하고 있었는데 로그인 시 헤더로 토큰을 발급해주며, 클라이언트는 로컬 스토리지에 해당 토큰을 저장해서 보관, 사용하고 있었다.
대충 뭐가 문제인지 감이 올 것이다.
JWT를 헤더로 전송하고 로컬 스토리지에 저장하는 방식은 XSS에 취약하다.
헤더 때문에 취약하다기 보다는 프론트가 헤더로 전달 받으면 저장할 장소가 로컬 스토리지 말곤 마땅한 곳이 없기 때문이다. 로컬/세션 스토리지는 js 코드로 접근이 가능해서 js 코드 삽입으로 JWT 탈취를 할 수 있다. 세션 스토리지나 리액트 상태관리 라이브러리는 브라우저를 닫으면 날아가기 때문에 사용자 경험에 악영향을 준다.
XSS 위협은 과거 세션을 사용한 인증 방식이 주를 이룰 땐 세션 ID를 토큰으로 전달하기 때문에 HttpOnly 속성으로 간단하게 막을 수 있어 거의 사라진 위협인데, MSA로 인해 stateless 한 JWT로 대세가 변경되다 보니 다시 과거의 취약점 위협이 증가하는 것이 아이러니.
이 XSS 공격을 막을 수 있는 방법이 js의 접근을 차단하는 것인데 이 방법 중 하나가 바로 HttpOnly 속성을 설정한 쿠키에 토큰을 보관하는 것이다.
그런데 문제가 하나 있다. JWT 인증 방식의 로그인 유지를 위한 특성 상 엑세스 토큰과 리프레시 토큰 이렇게 2가지 토큰으로 이루어져 있다는 것. 리프레시 토큰은 애초에 js로 접근하게 할 필요가 없기 때문에 HttpOnly 속성을 사용하면 된다.
하지만 보통 엑세스 토큰은 프론트에서 화면 표시에 필요한 유저의 간단한 정보를 담고 있어서 js로 꺼내와서 파싱을 할 수 있어야 하기 때문에 엑세스 토큰은 어쩔 수 없이 HttpOnly 속성을 사용하지 못한다. 그래서 XSS에 여전히 노출이 되게 되는데 공격을 통한 탈취가 되어도 피해를 최소화 할 수 있도록 엑세스 토큰의 유효 시간을 짧게 주는 방법으로 방어를 하겠지만 충분치 않다.
이 보안 위협을 어느정도 해결하기 위해서는 JWT의 특징인 stateless를 어느 정도 버리는 수밖에 없다. 약간 stateful 하게 만들어줘야 한다. 그 방법 중 하나로 Redis 같은 인메모리 DB를 사용해서 다중 접속이 가능한 서비스라면 블랙리스트를, 다중 접속이 불가능한 서비스라면 화이트리스트를 운영해 서버에서 토큰에 대한 제어를 할 수 있게 구현하는 것이다. 이렇게 되면 토큰에 대한 유효성 검증 로직이 서버에 추가되어야 하는데, 이로 인해 서버의 부하가 약간은 증가하지만 전통적인 풀 stateful 보다는 가볍기 때문에 장점이 있다.
하지만 쿠키를 사용하면 장점만 있느냐? 쿠키를 사용하면 XSS 공격으로 리프레시 토큰 탈취 방어는 되지만 쿠키는 브라우저가 자동으로 요청과 함께 보내버리기 때문에 CSRF 공격에 취약해지게 된다.
이 CSRF 공격을 방어하기 위해선 요청의 출처를 명확히 해주어야 한다. 그 방법 중 하나가 쿠키의 SameSite 속성이다. 브라우저가 다른 사이트의 요청과 함께 전송하지 않도록 해 CSRF 공격을 방지하는데 도움이 된다.
하지만 최신 브라우저가 아닐 경우에는 SameSite 속성을 지원하지 않는 경우도 있다. 모든 유저가 최신 브라우저를 사용하는 것은 아니기 때문에 CSRF 토큰을 함께 사용하는 것이 안전하다.
당연히 Https 프로토콜에서만 전송될 수 있도록 secure 속성도 넣어주면 더 좋겠지?
세션 인증 방식에선 CSRF 토큰을 세션 ID와 동일하게 세션 메모리에 저장했다. 하지만 JWT를 사용한다는 것은 stateless 하길 원한다는 것. CSRF 토큰도 최근 유행을 따라 기존 stateless하게 운영할 수 있는 방법이 있다.
서버에선 생성한 CSRF 토큰을 쿠키에 넣어서 보내주고 클라이언트는 서버에 요청을 할때마다 쿠키에서 꺼내서 요청 헤더를 통해 서버로 전송하게 하는 것이다. 그리고 서버에서 쿠키로 들어온 CSRF 토큰과 요청 헤더로 온 CSRF 토큰을 비교해서 유효성 검사를 하면 된다.
☝🏻 참고로 CSRF 토큰은 모든 요청에서 무조건 보내 검증해도 되긴 하지만, 주로 POST, PUT, DELETE 등 데이터를 변경하는 요청에만 보내서 검증하도록 해도 충분하다.
바로 csrf 쿠키를 별도로 관리하는 것보다는 엑세스 토큰과 라이프 사이클을 일치시켜 사용하는 것이다.
이외에도 리프레시 토큰이 조금 더 탈취에서 안전할 수 있도록 토큰 리프레시로 엑세스 토큰을 갱신할 때마다 리프레시 토큰도 새것으로 교체해주는 방법도 있다.(남은 만료 시간은 동일하게) 이건 다음에 다뤄보기로 하겠다.