JWT는 어디에 두고 로그아웃은 어떻게 하나

로그아웃해도 JWT가 만료까지 통과하는 이유, 서버에서 끊는 세 가지 방법, localStorage와 HttpOnly 쿠키가 각각 막는 공격.

동딩
2026.10.08·6분 읽기·

로그아웃이 localStorage.removeItem("token") 한 줄이면, 그 전에 복사된 토큰은 만료 시각까지 그대로 통과합니다.

서버 세션과 JWT는 로그인 상태를 어디에 두나

서버 세션은 로그인한 사용자를 서버 저장소에 적어 두고 브라우저에는 세션 ID만 쿠키로 줍니다.

JWT는 "user-42, 15분 뒤 만료" 같은 항목(RFC 7519의 클레임)에 서버 키로 만든 서명을 붙인 문자열입니다. 내용이 한 글자만 바뀌어도 서명 검증에 실패하지만, 내용은 암호화되지 않아 누구나 풀어 읽습니다.

JWT는 유효기간이 찍힌 놀이공원 자유이용권과 비슷합니다. 입구 직원은 도장과 날짜만 보고, 손님 명단이 없으니 잃어버린 표를 막지 못합니다.

  • 로그인 상태서버 저장소토큰 안 클레임
  • 요청마다 서버가세션 ID로 조회서명과 exp만 확인
  • 로그아웃세션 삭제로 끝exp까지 유효
  • 강제 만료바로 됨따로 장치 필요
서버 세션과 JWT가 로그인 상태를 기억하는 곳

서버가 명단 없이 토큰만 보고 판단하는 이 방식을 상태 없음(stateless)이라고 부릅니다. Spring Security 리소스 서버도 세션 없이 요청마다 서명과 클레임만 검사합니다.

로그아웃한 뒤에도 토큰이 통과하는 이유

서버가 아는 것은 토큰의 만료 시각(exp)뿐이라, 로그아웃 버튼은 브라우저에 있던 사본을 지울 수만 있습니다.

로그아웃한 뒤에도 복사된 JWT가 통과하는 순서
브라우저공격자서버
1로그인
2JWT 발급 (exp 15분)
3XSS로 토큰 사본 유출
4로그아웃 버튼: 토큰 삭제
5사본으로 API 요청
6서명 확인, 200 OK
브라우저 → 서버

로그인하면 서버가 JWT를 만들어 내주고 아무것도 저장하지 않습니다.

브라우저 → 공격자

공격자가 XSS(내 페이지에 스크립트를 끼워 넣는 공격)로 localStorage의 토큰을 읽어 갑니다.

브라우저

로그아웃은 브라우저에 있던 사본만 지웁니다.

공격자 → 서버

서버는 서명과 만료 시각만 보므로 exp까지 통과시킵니다.

서버에서 끊으려면 서버가 다시 무언가를 기억해야 합니다. 무엇을 기억하느냐에 따라 방법이 셋으로 갈립니다.

1짧은 exp + 리프레시 토큰기억: 리프레시 토큰exp가 지나면 끊김
2jti 거부 목록기억: 끊은 토큰의 ID바로 끊김
3사용자별 토큰 버전기억: 사용자마다 숫자 하나그 사용자 토큰 전부 바로
서버에서 JWT를 끊는 세 가지 방법

리프레시 토큰은 새 액세스 토큰을 받아 오는 데만 쓰는 오래 사는 토큰이고, jti는 토큰마다 붙는 고유 ID입니다. 1번과 2번을 Node 내장 crypto만으로 확인했습니다.

JWT 로그아웃 데모 코드와 실행 결과(Node v26.10.0 실행을 줄여 다시 그림)
jwt-demo.mjsdecode
const token = sign({
sub: "user-42", role: "admin",
jti, iat, exp });
log(dec(token.split(".")[1]));
실행 결과
{
"sub": "user-42",
"role": "admin",
"jti": "7e71714f-…",
"iat": 1791622911,
"exp": 1791623811
}
localStorage.token = token;
// XSS가 사본을 가져간 뒤 로그아웃
const stolen = localStorage.token;
localStorage.removeItem("token");
// verify: 서명·exp·거부 목록 확인
log(verify(stolen, now));
실행 결과
ok (sub=user-42)
denylist.set(jti, exp);
log(verify(stolen, now));
실행 결과
revoked
denylist.clear();
const later = now + 16 * 60;
log(verify(stolen, later));
실행 결과
expired
서명만 한 토큰

페이로드를 base64url로 풀기만 해도 role: admin이 그대로 보입니다.

브라우저에서 지운 뒤

브라우저 쪽 사본을 지워도 미리 복사한 토큰은 ok로 통과합니다.

거부 목록에 jti 등록

서버가 jti를 거부 목록에 넣자 같은 토큰이 revoked로 막힙니다.

16분 뒤

거부 목록이 없어도 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 요청에만 쿠키를 보내는 속성입니다.

java
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에 넣습니다.

java
// 검사기: 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부터 막아야 합니다.

Comments

댓글

powered by giscus ↗