포스트

JWT의 구조와 검증 - 서명 알고리즘, 만료, 그리고 폐기의 한계

JWT를 “로그인 상태를 서버에 저장하지 않아도 되는 토큰”으로 배우면, 로그아웃을 구현할 때 막힌다. 서버가 상태를 안 갖는 것이 장점이라고 했는데 “이 토큰은 이제 무효”라고 말하려면 상태가 필요하다. 이 모순이 JWT 설계의 핵심 제약이고, 구조를 보면 왜 그런지가 드러난다.


구조: 세 조각과 하나의 서명

header.payload.signature를 점으로 이은 문자열이고, 앞의 두 조각은 Base64URL 인코딩일 뿐 암호화가 아니다. 누구나 디코딩해 읽을 수 있다.

1
2
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMiLCJleHAiOjE3...} . 3f6Tn...
  {"alg":"HS256"}        {"sub":"123","exp":...}          서명

서명이 보장하는 것은 무결성과 출처이지 기밀성이 아니다. 토큰 페이로드에 주민번호나 내부 권한 구조를 넣으면 그대로 공개된다.

표준 클레임 중 검증에 쓰는 것은 다음과 같다(RFC 7519 4.1).

클레임뜻검증에서 하는 일
exp만료 시각지났으면 거부
nbf이 시각 이전에는 무효아직이면 거부
iat발급 시각토큰 나이 판단
iss / aud발급자 / 대상다른 서비스의 토큰을 받지 않기 위해
jti토큰 ID폐기 목록과 재사용 탐지에

iss와 aud를 검증하지 않으면, 같은 키를 쓰는 다른 서비스의 토큰이 통과한다. 검증 코드에서 자주 빠지는 항목이다. RFC 8725는 한 발급자가 여러 대상에게 토큰을 발급한다면 aud를 넣고 검증해야 한다고 정한다(RFC 8725 3.9).


서명 알고리즘과 두 가지 사고

HS256은 대칭키(HMAC, 비밀키 기반 메시지 인증 코드)다. 발급자와 검증자가 같은 비밀을 공유한다. 검증하는 쪽이 여럿이면 그 수만큼 비밀이 복제되고, 그중 하나가 새면 토큰을 위조할 수 있다.

RS256/ES256은 비대칭키다. 발급자만 개인키를 갖고 검증자는 공개키만 있으면 된다. 마이크로서비스 여럿이 검증한다면 이쪽이 맞다.

사고는 두 가지 형태로 난다. 둘 다 RFC 8725가 실제 공격 사례로 든다(RFC 8725 2.1).

alg: none. 초기 라이브러리 중 일부가 헤더의 alg를 그대로 믿고, none이면 서명 검증을 건너뛰었다. 공격자가 헤더를 바꾸고 서명을 비우면 통과한다.

알고리즘 혼동(algorithm confusion). RS256으로 발급하는 시스템에 alg를 HS256으로 바꾼 토큰을 보낸다. 검증 코드가 “헤더의 알고리즘으로 검증”하면, 공개키를 HMAC 비밀로 써서 검증한다. 공개키는 공개돼 있으므로 공격자가 유효한 서명을 만들 수 있다.

두 사고의 교훈은 같다. 알고리즘은 토큰이 정하는 것이 아니라 서버가 정한다. 검증 시 기대하는 알고리즘을 고정하고, 헤더의 alg는 그것과 일치하는지 확인하는 용도로만 쓴다. RFC 8725 3.1은 라이브러리 쪽 요구사항으로 이를 적는다.

“Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations.”

RFC RFC 8725 JSON Web Token Best Current Practices · Section 3.1 이 출처의 인용 모아 보기

라이브러리는 호출자가 허용 알고리즘 집합을 지정할 수 있게 해야 하고, 그 밖의 알고리즘은 쓰지 말아야 한다는 뜻이다.


폐기가 어려운 이유

서명이 유효하고 exp가 남아 있으면 그 토큰은 유효하다. 서버가 “무효”라고 말하려면 어딘가에 그 사실을 적어 두고 매 요청 조회해야 한다. 그 순간 “상태를 두지 않는다”는 전제가 깨진다.

현실적인 선택지는 셋이다.

방법대가
액세스 토큰 수명을 짧게(분 단위) + 리프레시 토큰폐기 지연이 액세스 토큰 수명만큼 남는다
폐기 목록(jti 블랙리스트)저장소 조회가 매 요청에 붙는다
토큰 버전 클레임 + 사용자별 버전 조회같은 조회 비용, 대신 목록이 자라지 않는다

실무에서 가장 흔한 것은 첫 번째이고, “로그아웃 즉시 무효”가 요구사항이면 두 번째나 세 번째가 된다. 세 방법 모두 상태를 어딘가에 둔다. 그래서 JWT가 없애는 것은 세션 저장소가 아니라 “모든 요청에서의 세션 조회”이고, 요구사항에 따라 그것이 돌아온다.

리프레시 토큰에는 회전(rotation)과 재사용 탐지가 따라온다. 리프레시할 때마다 새 토큰을 주고 옛것을 무효화하며, 무효화된 리프레시 토큰이 다시 오면 탈취로 보고 그 계정의 토큰 전체를 끊는다.


이 설명이 깨지는 곳

  • JWT는 세션의 대체재가 아니다. 단일 서버 웹 애플리케이션이면 서버 세션이 더 단순하고 폐기도 즉시다. JWT가 유리한 곳은 검증자가 여럿이거나 발급자와 검증자가 다른 조직일 때다.
  • 저장 위치가 보안의 절반이다. localStorage는 XSS(페이지에 주입된 스크립트)에 노출되고, 쿠키는 CSRF에 노출된다. HttpOnly + Secure + SameSite 쿠키가 기본이고, 그 경우 CSRF 대책이 따로 필요하다.
  • 시계가 어긋나면 exp·nbf 검증이 틀린다. 서버 간 NTP(네트워크 시각 동기화) 동기화가 전제이고, RFC 7519는 시계 차이를 흡수하려고 보통 몇 분 이내의 작은 허용 오차(leeway)를 둘 수 있다고 적는다(RFC 7519 4.1.4).
  • 토큰이 크면 헤더가 커진다. 권한을 전부 클레임에 넣으면 모든 요청에 그 크기가 붙는다.

무엇을 재면 확인되는가

  1. 폐기 목록을 붙였을 때 요청당 추가 지연과 저장소 부하. “상태 없음”이 실제로 얼마를 아끼는지가 여기서 나온다.
  2. 액세스 토큰 수명을 5분/30분/2시간으로 바꿔 가며 리프레시 트래픽의 양.
  3. 라이브러리가 alg를 고정하지 않았을 때 위조 토큰이 통과하는지. 직접 만들어 보는 것이 가장 확실하다.

실무와의 접점

토큰 기반 인증 정리에서 흐름을 다뤘는데, 그때는 “왜 서버 세션 대신 토큰인가”를 장점 목록으로 적었다. 지금 다시 쓴다면 폐기 요구사항부터 묻겠다. “로그아웃하면 즉시 막혀야 하는가”에 예라고 답하는 순간, JWT의 상태 없음은 설계에서 사라지고 남는 것은 서명된 클레임의 이점뿐이다. 그 이점만으로 충분한 경우도 많지만, 그것을 알고 고르는 것과 모르고 고르는 것은 다르다.


정리

  • 검증 코드는 알고리즘을 고정하고 exp, iss, aud를 함께 본다. 하나라도 빠지면 위조 토큰이나 다른 서비스의 토큰이 통과한다.
  • “로그아웃 즉시 무효”가 요구사항이면 JWT도 매 요청 상태를 조회한다. 그때 남는 이점은 서명된 클레임뿐이다.
참고한 자료외부 출처 3 · 블로그 글 2

외부 출처

이 블로그의 관련 글

Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다