로그아웃했는데 토큰이 아직 통한다면
한 줄 요약
서명된 JWT 액세스 토큰은 리소스 서버가 혼자 검증한다. 그래서 발급자가 "이 토큰은 이제 무효" 라고 해도 API 는 듣지 못하고, 토큰은 exp 까지 산다. 폐기를 즉시 반영하는 방법은 셋뿐이다 — 매번 발급자에게 묻거나(introspection), 리소스 서버가 차단목록을 들거나, 토큰 수명을 짧게 해 노출 창 자체를 줄이는 것. 셋 다 공짜가 아니다.
왜 이게 필요했나
사용자가 로그아웃을 눌렀다, 노트북을 잃어버렸다, 비밀번호가 새서 바꿨다. 이때 기대하는 것은 "그 순간부터 그 사람 토큰으로는 아무것도 안 된다" 다. 앞 모듈의 서버 세션이라면 저장소 키 하나를 지우면 끝났다. 그런데 JWT 는 자기 완결적이다. API 서버는 JWKS 로 서명과 exp 만 보고 통과시키므로, 발급자 쪽에서 세션을 지워도 그 사실을 알 길이 없다.
RFC 7009 는 클라이언트가 "이 토큰은 이제 안 쓴다" 고 발급자에게 알리는 폐기 엔드포인트를 표준화했다. 그런데 이 RFC 의 3절(Implementation Note)이 스스로 한계를 적어 둔다. 토큰이 자기 완결적이면 즉시 폐기에는 "currently non-standardized" 한 발급자-리소스 서버 간 연동이 필요하고, 다른 대안은 짧은 수명의 액세스 토큰을 refresh 토큰으로 계속 갱신하는 것이라고. 폐기 엔드포인트를 불렀다고 API 가 저절로 알게 되지는 않는다는 뜻이다.
어떻게 동작하나
세 전략을 나란히 놓으면 이렇다.
전략 폐기가 반영되는 시점 대가
짧은 TTL 최대 토큰 수명만큼 늦게 refresh 가 잦아져 발급자 부하
introspection 즉시 요청마다 발급자 왕복, 가용성이 묶임
로컬 차단목록(jti) 즉시(목록을 가진 서버만) 상태 저장소, 서버 간 전파
폐기(RFC 7009). 클라이언트가 POST /revoke 에 token 과 선택적인 token_type_hint(access_token·refresh_token)를 보낸다. 기밀 클라이언트는 자기 인증을, 공개 클라이언트는 client_id 를 함께 보내고, 서버는 그 토큰이 요청한 클라이언트에게 발급된 것인지 확인한다. 응답은 폐기에 성공해도, 원래 무효인 토큰이어도 200 이다 — 클라이언트가 그 오류로 할 수 있는 일이 없어서다. refresh 토큰을 폐기하면 서버가 액세스 토큰 폐기를 지원하는 경우 같은 grant 의 액세스 토큰도 무효화해야 하고(SHOULD), 액세스 토큰을 폐기할 때 refresh 까지 폐기하는 것은 선택(MAY)이다.
이 차이가 실무에서 그대로 드러난다. 이 실습의 Keycloak 26.0.7 에서 재 보면, refresh 토큰을 폐기하면 세션이 끝나 그 세션의 액세스 토큰이 introspection 에서 active=false 가 되고 refresh 그랜트는 400 이다. 반면 액세스 토큰만 폐기하면 refresh 로 새 액세스 토큰이 계속 나온다. 로그아웃이 refresh 를 폐기해야 하는 이유다. 엔드포인트 경로는 Keycloak 문서에 있다.
Introspection(RFC 7662). 리소스 서버가 받은 토큰을 RFC 7662 엔드포인트에 form 으로 POST 하면 {"active": true, ...} 가 온다. 만료·폐기·위조면 active=false 이고, 이유는 알려 주지 않는 것이 권고다(SHOULD NOT). 이 엔드포인트는 토큰 스캐닝을 막기 위해 호출자 인증을 MUST 요구한다 — 그래서 리소스 서버는 기밀 클라이언트(api-svc) 자격으로 부른다. RFC 7662 의 보안 고려사항은 캐시의 양면을 짚는다. 캐시를 짧게 잡으면 최신이지만 발급자 부하가 늘고, 길게 잡으면 "폐기된 토큰이 쓰일 수 있는 창" 이 생긴다.
차단목록. 폐기된 토큰의 jti 를 Redis 에 올리고 요청마다 확인한다. 핵심은 TTL 을 토큰의 남은 수명(exp - now) 으로 잡는 것이다. 만료된 토큰은 서명 검증 단계에서 어차피 거절되니 그 뒤로 기억할 필요가 없고, 그래서 목록의 크기가 "지금 살아 있는 폐기 토큰 수" 로 묶인다.
현장에서 만나는 모습
"로그아웃했는데 API 가 계속 된다" 는 신고는 대개 로컬 검증만 하는 API 에 긴 액세스 토큰 수명이 겹친 경우다. 이 렐름의 액세스 토큰 수명은 900초라, 로컬 검증만 하면 폐기된 토큰이 최대 15분을 더 산다. 수명을 줄이면 창은 줄지만 refresh 트래픽이 발급자에 몰린다. RFC 9700(OAuth 2.0 Security BCP) 4.14절은 refresh 토큰이 있기에 액세스 토큰을 짧게 발급해 유출 영향을 줄일 수 있다고 정리하고, 비밀번호 변경이나 인가 서버 로그아웃 같은 보안 이벤트 때 refresh 토큰을 자동으로 폐기해도 된다(MAY)고 적는다.
그래서 흔한 조합은 "짧은 액세스 토큰 + 로그아웃 때 refresh 폐기" 를 기본으로 깔고, 송금처럼 한 번의 오용도 비싼 API 에만 introspection 이나 차단목록을 얹는 것이다. introspection 을 전 API 에 걸면 발급자가 느려지는 순간 모든 서비스가 같이 느려진다 — 검증 비용이 가용성 결합으로 바뀌는 지점이다.
다음 실습에서 할 것
Keycloak 을 켜고 토큰을 받아 introspection 으로 active=true 를 확인한 뒤, RFC 7009 로 refresh 토큰을 폐기해 false 로 바뀌는 것을 본다. 이어서 리소스 서버에 로컬 검증 경로와 introspection 경로를 나란히 만들고, 폐기된 토큰이 한쪽에선 200, 다른 쪽에선 401 인 것을 확인하며 두 경로의 지연을 잰다. 마지막으로 jti 차단목록과 로그아웃을 붙여 왕복 없이 즉시 막는 방법까지 한 흐름으로 증명한다.