이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Logout은 왜 클라이언트 저장소 삭제만으로 부족한가?
- JWT access token, refresh token, server session은 각각 어떻게 폐기하는가?
- revocation과 rotation 로그는 어떤 사고 대응 근거를 남겨야 하는가?
개요
Logout은 “브라우저에서 token을 지운다”가 아니다.
인증 상태가 어디에 저장되어 있는지에 따라 끊어야 할 대상이 달라진다.
- 서버 세션이면 session store에서 세션을 폐기한다.
- refresh token이면 서버 저장소에서 token 또는 family를 폐기한다.
- JWT access token이면 짧은 TTL로 피해 시간을 줄이거나 revocation check를 추가한다.
- 모바일이나 여러 기기라면 기기별, 계정별, 전체 logout 범위를 정한다.
공격 시나리오
사용자가 공용 PC에서 로그아웃했다.
프론트엔드는 localStorage의 access token만 지웠다. 그러나 서버의 refresh token record는 살아 있고, 공격자는 이전에 탈취한 refresh token을 가지고 있다.
공격자는 refresh endpoint를 호출해 새 access token을 받는다.
사용자 입장에서는 로그아웃했지만 서버 입장에서는 인증 상태가 계속 연장된다.
취약한 요청/응답 예시
POST /logout HTTP/1.1
Host: api.example.com
Authorization: Bearer access-token
Cookie: refresh_token=rt-activeHTTP/1.1 204 No Content서버가 refresh token을 폐기하지 않고 204만 반환하면 브라우저에서 값을 지운 사용자만 보호된다. 이미 탈취된 refresh token은 계속 살아 있다.
주제별 핵심 판단
다음 이벤트는 logout과 비슷하거나 더 강한 폐기를 요구한다.
- 사용자가 명시적으로 logout했다.
- 비밀번호가 변경되었다.
- MFA가 해제되었다.
- 계정이 잠겼다.
- refresh token reuse가 감지되었다.
- 관리자 권한이 제거되었다.
- 사용자가 “모든 기기에서 로그아웃”을 눌렀다.
각 이벤트가 session, refresh token, access token에 미치는 영향을 정책으로 정해야 한다.
개선된 요청/응답 예시
폐기 정책은 저장소별로 나누어 적는다.
- 개선된 logout은 현재 기기 종료와 전체 기기 종료를 분리하고 서버 저장소의 token 상태를 갱신한다.
- revocation 로그에는 token 원문 대신 token hash, family id, device id, reason을 남긴다.
방어 설계
server session:
- current session invalidate
- all sessions for account option
refresh token:
- current token revoke
- token family revoke
- device scoped revoke
access token:
- short TTL
- jti denylist for high-risk events
- authority version or session version check모든 access token을 매 요청 DB 조회로 검증하면 JWT의 장점이 줄어든다. 반대로 전혀 폐기할 수 없으면 사고 대응이 약해진다. 고위험 서비스는 짧은 TTL, refresh token family 폐기, 권한 version check를 조합한다.
Spring/HTTP 예시
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@AuthenticationPrincipal AccountPrincipal principal,
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletResponse response,
HttpServletRequest request) {
logoutService.logoutCurrentDevice(
principal.accountId(),
refreshToken,
ClientSignal.from(request));
ResponseCookie expired = ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/api/auth/refresh")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, expired.toString());
return ResponseEntity.noContent().build();
}@Transactional
public void logoutCurrentDevice(long accountId, String rawRefreshToken, ClientSignal signal) {
if (rawRefreshToken == null) {
audit.logoutWithoutRefreshToken(accountId, signal);
return;
}
String hash = tokenHasher.hash(rawRefreshToken);
refreshTokenRepository.revokeByHash(accountId, hash, "user_logout");
audit.refreshTokenRevoked(accountId, "current_device", signal);
}비밀번호 변경은 더 넓게 폐기한다.
public void onPasswordChanged(long accountId) {
refreshTokenRepository.revokeAllFamilies(accountId, "password_changed");
sessionRegistry.revokeAll(accountId);
audit.accountSessionsRevoked(accountId, "password_changed");
}운영 로그와 감사 지점
level=INFO event=logout_completed result=success account_id=1004 scope=current_device refresh_family_revoked=false session_revoked=true trace_id=d210be01level=WARN event=token_family_revoked result=success account_id=1004 family_id=fam_91 reason=refresh_reuse_detected revoked_count=5 trace_id=d210be02level=INFO event=account_sessions_revoked result=success account_id=1004 reason=password_changed sessions=3 refresh_families=2 trace_id=d210be03사고 대응에서는 “사용자가 눌렀다”보다 “어떤 범위가 폐기되었는가”가 중요하다.
실전 판단 기준
- logout 후 같은 refresh token으로 재발급 요청이 거부되는가?
- logout API 성공이 resource server의 token 거부까지 보장하는지 확인한다.
- 권한 강등, 비밀번호 변경, 탈취 의심은 일반 logout보다 넓은 폐기 범위를 요구한다.
테스트 포인트
- logout 응답이 refresh cookie를 만료시키는가?
- 비밀번호 변경 후 모든 기기의 refresh token과 session이 폐기되는가?
- access token의 잔여 TTL이 제품 위험도에 비해 너무 길지 않은가?
- refresh token reuse 감지 시 family 단위 폐기가 일어나는가?
- 폐기 로그에 scope, reason, revoked count, trace id가 남는가?
위험 신호!
- logout이 localStorage 삭제만 수행한다.
- 서버의 refresh token record가 logout 후에도 ACTIVE다.
- 비밀번호 변경 후 기존 기기가 계속 refresh할 수 있다.
- access token TTL이 길고 revocation 보완이 없다.
- token family 개념이 없어 탈취 의심 시 어떤 token을 끊을지 모른다.
- revocation 로그가 없어 사고 후 폐기 범위를 증명할 수 없다.
확인 질문
확인 질문
- logout이 클라이언트 삭제만으로 부족한 이유는 무엇인가?
- 이미 탈취된 token이나 서버 저장소의 refresh token은 클라이언트 저장소와 무관하게 계속 사용될 수 있기 때문이다.
- refresh token family를 폐기하는 대표 상황은 무엇인가?
- refresh token 재사용 탐지, 비밀번호 변경, 계정 잠금, 전체 기기 logout, MFA 해제 같은 고위험 이벤트다.
- JWT access token의 폐기가 어려운 이유는 무엇인가?
- 서버 조회 없이 검증하도록 설계되기 때문에 만료 전 즉시 무효화하려면 denylist나 version check 같은 추가 상태가 필요하기 때문이다.