포스트

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

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

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

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

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

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

표준 클레임 중 검증에 쓰는 것은 다음과 같다.

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

iss와 aud를 검증하지 않으면, 같은 키를 쓰는 다른 서비스의 토큰이 통과한다. 검증 코드에서 자주 빠지는 항목이다.

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

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

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

사고는 두 가지 형태로 난다.

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

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

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

폐기가 어려운 이유

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

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

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

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

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

이 설명이 깨지는 곳

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

무엇을 재면 확인되는가

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

실무와의 접점

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

정리

  • 헤더와 페이로드는 인코딩일 뿐이다. 서명은 무결성을 주지 기밀성을 주지 않는다.
  • exp뿐 아니라 iss와 aud를 검증해야 다른 서비스의 토큰이 통과하지 않는다.
  • 알고리즘은 서버가 고정한다. 토큰 헤더를 믿으면 alg: none과 알고리즘 혼동이 열린다.
  • 폐기하려면 상태가 필요하다. 짧은 수명, 폐기 목록, 토큰 버전 셋 다 어딘가에 상태를 둔다.
  • 리프레시 토큰에는 회전과 재사용 탐지가 따라온다.

참고

Spring과 JVM 백엔드
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다