RTR (Refresh Token Rotation)
JWT 저장 방식
예전에 JWT의 토큰 저장 방식, 구체적으로는 상태 저장(Stateful) 방식과 상태 비저장(Stateless) 방식에 대해 포스팅한 적이 있었다.
요약하자면 JWT 자체는 Stateless한 인증 방식이지만, 이는 보안상 유리해서가 아니라 사용하기 편리해서다. 편리한 만큼 Stateless한 방식에는 여러 보안 취약점이 존재하는데, 이를 개선하기 위한 방법 중 하나가 토큰을 DB나 메모리에 저장해두고 유효성을 한 번 더 검증하는 것이다.
하지만 이렇게 하면 JWT의 가장 큰 장점인 Stateless의 의미가 퇴색된다. 확장성이 떨어지고, 인증 때마다 저장소를 조회해야 하니 성능상 불리해진다.
지난 글의 결론은 상태 저장과 비저장 방식을 적절히 섞어 쓰자는 것이었다. 자주 쓰이는 AccessToken은 Stateless로 하되 수명을 짧게 하여 탈취 리스크를 줄이고, RefreshToken은 서버(DB)에 저장해 두고 재발급 때마다 대조하자는 내용이었다.
그렇게 하면 RefreshToken이 탈취당해도 서버 측에서 대응이 가능해진다. 하지만 대응이 늦어지면 여전히 치명적일 수 있다. 그래서 오늘은 해당 개념에서 한 단계 더 나아가, 더욱 안전하게 JWT를 다룰 수 있는 RTR(Refresh Token Rotation) 에 대해 정리해 보려 한다.
RTR
RTR은 이름 그대로 Refresh Token을 돌려쓰는(Rotation) 방식이다.
Refresh Token Rotation
위 사진을 보면 이해하기 쉽다. 한마디로 "AccessToken을 재발급 받을 때 RefreshToken도 같이 재발급해 주고, 기존 RefreshToken은 즉시 폐기(사용 불가)한다" 라고 이해하면 된다.
Why?
그럼 굳이 이렇게까지 해야 하는 이유는 뭘까? 당장 눈에 보이는 장점은 하나다. RefreshToken이 탈취당하더라도 이미 사용된 토큰이라면 재사용하지 못하니 보안상 더 안전하다.
감시당하고 있다.
사실 이것 말고 더 중요한 이유가 하나 더 있는데, 바로 탈취 감지에 용이해진다는 점이다. 보안은 뚫리지 않게 막는 것도 중요하지만, 뚫리더라도 빠르게 대응할 수 있는 환경을 만드는 것도 중요하다.
만약 해커가 탈취한(이미 폐기된) RefreshToken으로 재발급을 시도하거나, 반대로 해커가 먼저 선수쳐서 재발급을 받은 뒤 사용자가 기존 토큰을 쓴다면 어떻게 될까? 서버는 "이미 사용된 토큰을 다시 사용하려는 시도" 를 감지할 수 있다. 이 경우 즉시 사용자의 모든 계정을 로그아웃 시키고 보안 조치를 취할 수 있게 된다.
구현에 주의할점
'그럼 구현은 간단한 거 아니야?'라고 생각할 수 있고 반쯤은 맞는 말이다. DB의 RefreshToken을 새것으로 교체(Update)만 해주면 되니까.
하지만 실제 운영 환경에서는 동시성 이슈라는 복병이 있다. 만약 하나의 계정에서 여러 요청을 동시에 보냈는데, 하필 그때 AccessToken이 만료되었다면? 여러 요청이 동시에 재발급을 요청할 것이고, 첫 번째 요청이 처리되는 순간 기존 RefreshToken은 폐기된다. 그럼 간발의 차로 들어온 나머지 요청들은 "만료된 토큰"이라며 에러를 뱉거나, 심하면 이를 탈취 시도로 오인해 로그아웃될 수도 있다.
이에 대한 해결책으로 하나의 요청만 처리하고 해당 작업 동안 Lock을 걸거나, 짧은 유예 기간(Grace Period)을 두는 등의 로직을 구현해 둘 필요가 있다.
In-memory DB
redis
여기서 한 가지 더 생각해야 할 건 Redis 같은 In-memory DB 의 사용이다. 여러 관점에서 RDB보다 유리하다.
-
데이터의 중요도: RefreshToken은 서버가 꺼졌다 켜져서 날아가더라도, 사용자가 다시 로그인하면 그만인 데이터다. 굳이 영구적인 저장이 필요 없다.
-
접근 주기: RTR 방식을 쓰면 RefreshToken의 수명도 사실상 AccessToken 주기(보통 5분~30분)와 비슷해진다. 5분마다 조회하고, 삭제하고, 다시 쓰는 작업을 RDB에서 수행하는 건 성능상 매우 비효율적이다.
-
TTL(Time To Live): 마지막으로 이건 Redis의 장점이지만 TTL(Time To Live) 기능을 사용할 수 있다는 것이다. 간단하게 설명하면 데이터의 수명을 처음부터 정하여, 수명이 다 되면 자동으로 데이터를 정리해주는 기능이다. 그럼 토큰이 시간이 다 되어 만료되더라도 그걸 따로 체크해주는 로직을 만들 필요가 없어진다.
자주 조회되고 빈번하게 갱신되며, 영구 저장이 필요 없다면 In-memory DB가 답이다. 즉, RTR을 제대로 구현하려면 Redis의 도입은 거의 필수라고 볼 수 있다.
단점
원래 보안이 강력해질 수록 단점은 생기기 마련이다.
보안을 위해 편의성을 포기한다는 걸 단점이라고 할 수는 없지만 JWT의 목적이 인증의 편의성인 것을 생각해보면 이는 명백한 단점이기도 하다.
하지만 구현적인 측면에서는 개발자가 갈려가며 구현하면 되는 문제니 큰 문제는 아닐 수도 있다.
그럼 와닿을 만한 단점은 한가지다.
사용자 경험이 불편해질 수 있다.
잘 구현하기만 하면 되는 문제는 아니다.
네트워크 상황이나, 클라이언트의 문제를 개발자인 우리가 어떻게 해줄 수는 없다.
즉, 위와 같은 상황이 닥쳤을때 재발급에 실패할 것이고 그럼 다시 로그인해야하는 상황이 Stateless할때보다 더 많이 생길 가능성이 높다.
그렇기 때문에 무조건적인 RTR을 도입하기보다는 현재 서비스의 보안상 중요도를 판단하여 기술을 적용할 줄 아는 유연성을 가지는 것도 중요하다.
마무리
과거 공부할 때는 JWT를 보며 의문을 많이 가졌다. 처음 구현해 봤을 땐 너무 허술해 보였기 때문이다.
이제는 JWT가 보안을 위해서가 아닌 편의성을 위해 사용한다는 점도 알고, 웬만하면 세션을 사용하는 게 마음 편하다는 것도 알지만, 다중 플랫폼 환경과 OAuth 지원 등을 위해서는 토큰 사용이 필수적이기도 하다.
그렇기에 토큰을 안전하게 지키고, 탈취당하더라도 바로 대응할 수 있는 시스템을 만들기 위해 노력하는 것이 중요할 것 같다.