JWT는 어디에 두고 로그아웃은 어떻게 하나
로그아웃해도 JWT가 만료까지 통과하는 이유, 서버에서 끊는 세 가지 방법, localStorage와 HttpOnly 쿠키가 각각 막는 공격.
로그아웃이 localStorage.removeItem("token") 한 줄이면, 그 전에 복사된 토큰은 만료 시각까지 그대로 통과합니다.
서버 세션과 JWT는 로그인 상태를 어디에 두나
서버 세션은 로그인한 사용자를 서버 저장소에 적어 두고 브라우저에는 세션 ID만 쿠키로 줍니다.
JWT는 "user-42, 15분 뒤 만료" 같은 항목(RFC 7519의 클레임)에 서버 키로 만든 서명을 붙인 문자열입니다. 내용이 한 글자만 바뀌어도 서명 검증에 실패하지만, 내용은 암호화되지 않아 누구나 풀어 읽습니다.
JWT는 유효기간이 찍힌 놀이공원 자유이용권과 비슷합니다. 입구 직원은 도장과 날짜만 보고, 손님 명단이 없으니 잃어버린 표를 막지 못합니다.
- 로그인 상태서버 저장소토큰 안 클레임
- 요청마다 서버가세션 ID로 조회서명과 exp만 확인
- 로그아웃세션 삭제로 끝exp까지 유효
- 강제 만료바로 됨따로 장치 필요
서버가 명단 없이 토큰만 보고 판단하는 이 방식을 상태 없음(stateless)이라고 부릅니다. Spring Security 리소스 서버도 세션 없이 요청마다 서명과 클레임만 검사합니다.
로그아웃한 뒤에도 토큰이 통과하는 이유
서버가 아는 것은 토큰의 만료 시각(exp)뿐이라, 로그아웃 버튼은 브라우저에 있던 사본을 지울 수만 있습니다.
로그인하면 서버가 JWT를 만들어 내주고 아무것도 저장하지 않습니다.
공격자가 XSS(내 페이지에 스크립트를 끼워 넣는 공격)로 localStorage의 토큰을 읽어 갑니다.
로그아웃은 브라우저에 있던 사본만 지웁니다.
서버는 서명과 만료 시각만 보므로 exp까지 통과시킵니다.
서버에서 끊으려면 서버가 다시 무언가를 기억해야 합니다. 무엇을 기억하느냐에 따라 방법이 셋으로 갈립니다.
리프레시 토큰은 새 액세스 토큰을 받아 오는 데만 쓰는 오래 사는 토큰이고, jti는 토큰마다 붙는 고유 ID입니다. 1번과 2번을 Node 내장 crypto만으로 확인했습니다.
페이로드를 base64url로 풀기만 해도 role: admin이 그대로 보입니다.
브라우저 쪽 사본을 지워도 미리 복사한 토큰은 ok로 통과합니다.
서버가 jti를 거부 목록에 넣자 같은 토큰이 revoked로 막힙니다.
거부 목록이 없어도 15분짜리 exp가 지나면 expired로 막힙니다.
거부 목록은 Redis처럼 키에 만료 시간을 거는 저장소에 두면 만료된 항목이 저절로 빠집니다. OWASP JWT 치트시트는 키를 jti와 iss로 잡고 토큰 원문이나 해시는 쓰지 말라고 합니다.
localStorage와 HttpOnly 쿠키가 막는 공격
localStorage는 같은 페이지의 스크립트라면 누구나 읽습니다. 그래서 OWASP HTML5 보안 치트시트는 세션 식별자를 여기에 두지 말라고 합니다. XSS 한 번이면 토큰이 공격자 컴퓨터로 넘어가 exp까지 쓰입니다.
HttpOnly 쿠키는 스크립트가 읽지 못하지만, MDN 설명대로 fetch() 요청에는 브라우저가 자동으로 실어 보냅니다. 그래서 다른 사이트가 사용자 브라우저를 시켜 내 서버로 요청을 보내는 CSRF를 따로 막아야 합니다. SameSite=Strict를 붙이면 다른 사이트에서 시작한 요청에는 쿠키가 실리지 않습니다.
| localStorage | 메모리 변수 | HttpOnly 쿠키 | |
|---|---|---|---|
| XSS로 토큰 읽기 | 읽힘 | 어렵지만 가능 | 못 읽음 |
| XSS로 요청 대행 | 가능 | 가능 | 가능 |
| CSRF | 해당 없음 | 해당 없음 | 대비 필요 |
| 새로고침 뒤 | 남음 | 사라짐 | 남음 |
세 곳 모두 XSS가 내 페이지 안에서 fetch()로 요청을 대신 보내는 것은 막지 못합니다. 브라우저 앱용 OAuth 모범 사례인 RFC 10017도 이 공격은 앱 수준에서 막을 수 없다고 봅니다.
Spring에서 쿠키로 내리고 거부 목록으로 끊기
로그인 응답에서 토큰을 본문 대신 쿠키로 내립니다. Secure는 https 요청에만 쿠키를 보내는 속성입니다.
ResponseCookie cookie = ResponseCookie.from("access_token", jwt) .httpOnly(true) .secure(true) .sameSite("Strict") .path("/") .maxAge(Duration.ofMinutes(15)) .build();response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());리소스 서버는 기본으로 Authorization 헤더에서 토큰을 찾으니, 쿠키에서 읽는 BearerTokenResolver를 따로 등록해야 합니다. 거부 목록 검사는 공식 문서의 검사기 예제처럼 OAuth2TokenValidator로 만들어 기본 검사와 함께 NimbusJwtDecoder에 넣습니다.
// 검사기: jti가 거부 목록에 있으면 실패public OAuth2TokenValidatorResult validate(Jwt jwt) { if (Boolean.TRUE.equals(redis.hasKey("denylist:" + jwt.getId()))) { return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token")); } return OAuth2TokenValidatorResult.success();}// 로그아웃 API: exp까지 남은 시간만큼만 보관redis.opsForValue().set("denylist:" + jwt.getId(), "1", Duration.between(Instant.now(), jwt.getExpiresAt()));jwt.getId()가 jti 클레임이라, 발급할 때 jti를 넣어야 이 검사기가 동작합니다.
자주 하는 오해 세 가지
"React가 출력을 이스케이프하니 localStorage도 괜찮다"는 답이 자주 보입니다. 이 질문에 채택된 Stack Overflow 답변은 분석·광고 같은 서드파티 스크립트가 하나만 오염돼도 localStorage가 통째로 읽힌다고 반박합니다.
"HttpOnly 쿠키에 두면 XSS는 신경 쓰지 않아도 된다"도 틀렸습니다. 위 표처럼 요청 대행은 그대로이고, OWASP CSRF 치트시트는 XSS가 모든 CSRF 방어를 무력화한다고 적습니다. SameSite도 CSRF 토큰을 대신하지 못하는 보조 수단으로 봅니다.
"서명 키를 바꾸면 로그아웃된다"는 맞지만 모든 사용자가 한꺼번에 로그아웃됩니다. 한 사람만 끊으려면 거부 목록이나 사용자별 토큰 버전을 씁니다.
어떻게 고르면 되나
브라우저 앱이라면 액세스 토큰은 짧게 두고 HttpOnly·Secure·SameSite 쿠키에 담습니다. 로그아웃을 바로 반영해야 할 때만 jti 거부 목록을 씁니다. 서버를 하나 더 둘 수 있다면 RFC 10017이 권하는 BFF(브라우저 대신 토큰을 쥐고 API를 부르는 프런트 전용 서버)로 토큰을 아예 브라우저에 내리지 않을 수도 있습니다. 어느 쪽이든 XSS가 뚫리면 요청 대행은 남으니 XSS부터 막아야 합니다.