훔친 refresh 토큰을 서버는 어떻게 알아채나
한 줄 요약
access 토큰은 짧게 살리고 refresh 토큰으로 갈아 끼운다. 그 대신 refresh 는 한 번 쓰면 바뀌고(회전), 이미 쓴 refresh 가 다시 들어오면 그 로그인에서 이어진 토큰 전부를 끊는다(재사용 탐지). 서버가 누가 도둑인지 몰라도 도둑질을 멈추게 하는 설계다.
왜 이게 필요했나
access 토큰을 짧게 살리면 새어 나가도 피해 창이 짧다. RFC 9700 4.14 는 refresh 토큰이 바로 이것을 가능하게 한다고 적는다 — 사용자를 다시 로그인시키지 않고도 수명이 짧고 범위가 좁은 access 를 계속 내줄 수 있기 때문이다.
문제는 위험이 refresh 쪽으로 옮겨 간다는 점이다. 같은 절은 refresh 가 그 클라이언트가 받은 권한 전체를 대표하고, 훔쳐서 재생하면 공격자가 access 를 마음대로 찍어 낼 수 있다고 경고한다. 서버에 비밀을 둔 기밀 클라이언트는 refresh 를 쓸 때 클라이언트 인증을 하므로 토큰만으로는 못 쓴다(RFC 6749 10.4). 그러나 브라우저 SPA 나 모바일 앱 같은 공개 클라이언트는 숨길 비밀이 없다.
그래서 RFC 9700 — 오랫동안 draft-ietf-oauth-security-topics 라는 초안으로 불리다 2025년 1월 BCP 240 으로 발행된 문서 — 은 2.2.2절에서 공개 클라이언트의 refresh 는 sender-constrained 이거나 회전을 써야 한다(MUST)고 정한다. 앞쪽은 DPoP·mTLS 로 토큰을 클라이언트 키에 묶는 방법이고, 이 실습은 뒤쪽을 직접 만든다.
어떻게 동작하나
회전은 refresh 로 새 access 를 받을 때마다 새 refresh 도 함께 주는 것이다. RFC 9700 원문은 "The previous refresh token is invalidated, but information about the relationship is retained" 라고 쓴다. 옛 토큰을 지우지 않고 '썼음' 표시만 남기는 이유가 여기 있다. 지워 버리면 다시 들어온 옛 토큰이 처음 보는 쓰레기 값인지 이미 쓴 토큰인지 구별할 수 없고, 그러면 재사용을 탐지할 근거가 사라진다.
재사용 탐지는 이렇게 성립한다. 토큰이 새서 공격자와 정상 클라이언트가 둘 다 쓰면, 먼저 쓴 쪽이 회전시켜 버리므로 나중 쪽은 반드시 무효가 된 토큰을 내민다. 서버는 누가 공격자인지 모른다 — 공격자가 먼저 썼을 수도 있다. 그래서 RFC 9700 은 이때 활성 refresh 를 폐기하라고 한다. 정상 사용자도 다시 로그인해야 하지만 공격은 거기서 멈춘다. 한 번의 로그인에서 이어진 토큰 사슬을 계열(family)이라 부르고, 폐기는 계열 단위로 한다.
POST /login fam:F state=active rt1(uses=0) at1
POST /token rt1 rt1 uses=1 → rt2, at2 정상 회전
POST /token rt1 uses=2 → 재사용! fam:F state=revoked → 400 invalid_grant
POST /token rt2 계열이 죽었음 → 400 invalid_grant
GET /api/me at2 계열이 죽었음 → 401 invalid_token
응답 코드도 표준을 따른다. 무효·만료·폐기된 refresh 에 토큰 엔드포인트는 400 invalid_grant 로 답하고(RFC 6749 5.2), 보호된 API 는 무효한 access 에 401 과 WWW-Authenticate: Bearer error="invalid_token" 을 준다(RFC 6750 3.1). 로그아웃은 RFC 7009 폐기 엔드포인트 모양으로 만든다. refresh 를 폐기하면 같은 승인에서 나온 access 도 무효로 하는 것이 권고(SHOULD)이고, 모르는 토큰이 와도 200 이다 — 그 토큰을 못 쓰게 한다는 목적은 이미 이뤄졌기 때문이다.
마지막으로 '썼음' 표시는 원자적으로 해야 한다. 읽고 비교하고 쓰기를 따로 하면 같은 토큰으로 동시에 온 두 요청이 모두 "아직 안 씀" 을 보고 둘 다 새 토큰을 받는다. Redis 의 HINCRBY 는 증가와 결과 반환이 한 명령이라, 1 을 보는 요청은 언제나 하나뿐이다.
현장에서 만나는 모습
회전을 켜고 나면 "가끔 혼자 로그아웃된다" 는 신고가 온다. 탭 두 개가 동시에 만료된 access 를 갱신하려고 같은 refresh 를 두 번 보내면, 두 번째가 재사용으로 보여 계열이 통째로 날아간다. 공격이 아니라 경쟁 상태다. 고칠 곳은 탐지가 아니라 클라이언트다 — 갱신 요청을 한 곳으로 모아 한 번만 보내게 한다. Keycloak 같은 제품이 회전 켜기와 '허용할 재사용 횟수' 를 따로 설정하게 두는 것도 이 때문인데, 횟수를 늘리는 만큼 탐지는 느슨해진다.
또 하나는 오래 방치된 refresh 다. RFC 9700 은 클라이언트가 한동안 refresh 를 쓰지 않으면 만료시키라고(SHOULD) 하고, 로그아웃이나 비밀번호 변경 같은 보안 사건에 자동 폐기해도 된다(MAY)고 한다. 이 실습에서는 Redis 키 TTL 이 비활성 만료를 맡는다.
다음 실습에서 할 것
Redis 에 계열을 저장하는 작은 발급자를 세운다. 로그인으로 토큰 한 쌍을 받고, refresh 로 새 access 를 받고, 옛 refresh 가 거절되는지 본다. 그다음 훔친 옛 refresh 를 다시 제출해 정상 클라이언트의 최신 refresh 와 access 까지 함께 죽는지 확인하고, 짧은 access 수명과 로그아웃 폐기를 거쳐 전체 흐름을 한 스크립트로 증명한다.