it-box

JWT 구조와 검증 방법 정리

JWT의 header·payload·signature 구조, 디코딩과 서명 검증의 차이, exp·iat·nbf 해석, alg=none 문제, 토큰 저장 위치를 실제 예제로 설명합니다.

이 글이 다루는 문제

로그인 API가 eyJhbGciOi... 같은 긴 문자열을 내려주면, 프론트엔드 개발자는 보통 이 문자열을 개발자 도구에 붙여 넣어 봅니다. 그 안에 사용자 ID가 있는지, 만료 시간이 언제인지 확인하려는 것입니다. 이 작업 자체는 문제가 없습니다. 다만 같은 문자열을 두고 "디코딩했다"와 "검증했다"를 혼동하면 보안 사고로 이어질 수 있습니다. 이 글에서는 JWT(JSON Web Token)의 구조부터 설명하고, 디코딩과 검증이 어떻게 다른지, 만료 시간 클레임을 어떻게 읽는지, 토큰을 브라우저 어디에 보관하는지까지 순서대로 정리합니다.

JWT의 기본 규격은 RFC 7519에 정의되어 있습니다. 서명 알고리즘 이름과 설명은 RFC 7518에 있습니다. 아래 내용은 이 두 문서의 개념을 기준으로 하되, 실제 값은 모두 직접 계산해서 확인한 예제입니다.

JWT의 세 부분

JWT는 점(.)으로 구분된 세 부분으로 구성됩니다.

  1. Header: 토큰 타입과 서명 알고리즘을 담습니다. 보통 {"alg":"HS256","typ":"JWT"} 형태입니다.
  2. Payload: 클레임(claim)이라고 부르는 값들을 담습니다. 사용자 ID(sub), 발급자(iss), 만료 시각(exp) 같은 항목이 여기에 들어갑니다.
  3. Signature: header와 payload를 키로 서명한 값입니다.

header와 payload는 JSON을 Base64URL로 인코딩한 것입니다. 중요한 점은 이 두 부분이 암호화된 것이 아니라는 사실입니다. 누구나 Base64URL을 풀면 내용을 읽을 수 있습니다. 그래서 비밀번호나 주민등록번호 같은 민감한 값은 payload에 넣으면 안 됩니다. 서명은 "이 내용이 중간에 바뀌지 않았다"는 것만 보장합니다. 내용을 숨기지는 않습니다.

다음 예제는 JWT 파서의 기본 입력값과 같은 토큰입니다. 세 부분을 Base64URL로 풀면 다음과 같습니다.

header    : {"alg":"HS256","typ":"JWT"}
payload   : {"sub":"1234567890","name":"John Doe","iat":1516239022}
signature : SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c (Base64URL로 표시한 서명 값)

이 토큰의 서명은 비밀 키 your-256-bit-secret으로 header와 payload를 이어 붙인 문자열에 HMAC-SHA256을 적용한 값과 일치합니다. 이 점을 Node.js로 직접 확인했습니다. 같은 입력에 다른 키를 쓰면 일치하지 않습니다.

디코딩과 검증은 다른 작업이다

디코딩은 문자열을 나누고 Base64URL을 풀어 JSON으로 읽는 작업입니다. 키가 필요하지 않습니다. 반면 검증은 서버가 가진 비밀 키(또는 공개 키)로 서명을 다시 계산해서 토큰이 진짜인지 판단하는 작업입니다.

JWT 파서는 디코딩 도구입니다. 소스를 보면 jwtDecode 함수로 header와 payload를 읽고, 클레임마다 이름과 설명을 붙여 보여 줍니다. 서명은 계산하지 않습니다. 따라서 이 도구에서 값이 정상적으로 보인다는 사실은 "이 토큰이 서버에서 발급됐다"는 증거가 아닙니다. 누구든 원하는 payload로 토큰을 만들 수 있기 때문입니다.

서버에서 검증할 때는 최소한 다음 항목을 확인해야 합니다.

  • 서명이 서버가 알고 있는 키와 맞는지
  • 알고리즘이 서버가 기대하는 것과 같은지
  • exp가 현재 시각보다 뒤인지
  • nbf가 현재 시각보다 앞이 아닌지
  • iss와 aud가 이 서비스를 가리키는지

대부분의 JWT 라이브러리는 이 과정을 함수 하나로 처리합니다. 직접 구현할 때는 서명 비교를 상수 시간 비교 함수로 해야 하고, 시간 검사에는 약간의 허용 오차(clock skew)를 둘지 정해야 합니다.

exp, iat, nbf 읽는 법

시간 관련 클레임은 모두 Unix 시간(1970년 1월 1일 0시 UTC 이후 경과한 초)입니다. 밀리초가 아니라 초 단위라는 점이 자주 실수하는 부분입니다.

  • iat (Issued At): 토큰이 발급된 시각입니다.
  • nbf (Not Before): 이 시각 이전에는 토큰을 받아들이면 안 됩니다.
  • exp (Expiration Time): 이 시각 이후에는 토큰을 받아들이면 안 됩니다.

예를 들어 2026년 11월 10일 오전 9시(KST)에 발급되고 1시간 뒤 만료되는 토큰을 만들어 보겠습니다. KST는 UTC보다 9시간 빠릅니다. 따라서 KST 오전 9시는 UTC 기준 같은 날 0시입니다. 계산 결과는 다음과 같습니다.

iat = 1794268800  →  2026-11-10T00:00:00Z  (KST 09:00:00)
exp = 1794272400  →  2026-11-10T01:00:00Z  (KST 10:00:00)
exp - iat = 3600초 = 1시간

이 값을 사용해 만든 토큰은 다음과 같습니다. header와 payload는 Base64URL 인코딩 결과이고, 서명은 dev-only-secret 키로 계산했습니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTc5NDI2ODgwMCwiZXhwIjoxNzk0MjcyNDAwfQ.1zUNP8gfGW4Zhzt2C0og_C-1V0qU7rbjEKNNX62wCN8

payload를 풀면 {"sub":"user-42","role":"editor","iat":1794268800,"exp":1794272400}입니다. JWT 파서에 이 토큰을 넣으면 exp와 iat 옆에 브라우저의 로케일 형식으로 날짜가 표시됩니다. 이 도구는 날짜를 보여 줄 뿐, 지금 만료되었는지는 판단하지 않습니다. 또한 날짜 표시는 브라우저의 시간대를 따르므로, 한국 사용자가 한국 시간을 보려면 브라우저 설정이 한국 시간대인지 확인해야 합니다.

시간 클레임을 다룰 때 흔한 실수는 다음과 같습니다.

  • Date.now()(밀리초)와 exp(초)를 직접 비교하는 경우입니다. 값이 1000배 차이 나서 토큰이 영원히 유효하거나 바로 만료됩니다.
  • 서버와 클라이언트의 시계가 몇 초 어긋난 경우입니다. 클라이언트에서 "아직 유효"라고 판단해도 서버는 만료로 거절할 수 있습니다.
  • 만료 시각을 로컬 시간으로 잘못 해석하는 경우입니다. exp는 항상 UTC 기준 Unix 시간입니다.

alg=none과 알고리즘 혼동 공격

header의 alg 값은 토큰을 검증할 방법을 알려 줍니다. 여기서 두 가지 위험한 패턴이 알려져 있습니다.

첫 번째는 alg: none입니다. 이 값은 서명이 없다는 뜻입니다. 다음은 header가 {"alg":"none","typ":"JWT"}이고 payload가 {"sub":"1"}인 토큰입니다. 서명 부분이 비어 있습니다.

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIn0.

이 문자열은 디코딩하면 정상처럼 보입니다. 서버가 alg 값을 그대로 믿고 검증을 건너뛰면, 누구든 sub를 임의로 바꾼 토큰을 통과시킬 수 있습니다. 그래서 검증 라이브러리에는 허용할 알고리즘 목록을 명시해야 합니다. 예를 들어 algorithms: ['HS256']처럼 고정해야 합니다.

두 번째는 알고리즘 혼동(algorithm confusion)입니다. RS256(공개 키 기반)으로 발급하는 서버가 검증 시 alg 값을 보고 HS256(공유 키 기반)으로 처리하는 경우가 문제입니다. 공개 키는 누구나 알 수 있으므로, 공격자는 그 공개 키를 HMAC 비밀 키로 사용해 서명할 수 있습니다. 이 역시 검증 시 알고리즘을 서버 설정으로 고정하면 막을 수 있습니다.

정리하면 다음과 같습니다.

  • 토큰의 alg 값을 신뢰하지 않습니다.
  • 허용 알고리즘은 코드나 설정에 고정합니다.
  • none은 운영 환경에서 허용하지 않습니다.

토큰을 어디에 저장할까

브라우저에서 JWT를 저장하는 대표적인 방법은 세 가지입니다.

  1. localStorage 또는 sessionStorage: 자바스크립트로 읽고 쓸 수 있어 구현이 간단합니다. 대신 사이트에 XSS 취약점이 하나라도 있으면 공격 스크립트가 토큰을 그대로 가져갈 수 있습니다.
  2. HttpOnly 쿠키: 자바스크립트에서 읽을 수 없어 XSS로 토큰이 유출될 가능성을 줄입니다. 대신 쿠키는 요청마다 자동으로 전송되므로 CSRF 대책이 필요합니다. SameSite 속성과 CSRF 토큰을 함께 고려해야 합니다.
  3. 메모리(변수): 페이지를 새로고침하면 사라집니다. 짧은 수명의 액세스 토큰과 리프레시 토큰을 함께 쓰는 구조에서 자주 사용합니다.

어느 방법도 완벽하지 않습니다. 사내 관리자 페이지처럼 사용자 수와 보안 요구가 높은 서비스라면 HttpOnly 쿠키를 기본으로 두고 CSRF 방어를 함께 설계하는 편이 일반적입니다. 반대로 공개 API를 호출하는 SPA라면 액세스 토큰은 짧게 유지하고, 리프레시 토큰은 쿠키로 분리하는 방식이 많이 쓰입니다. 어떤 방식이든 토큰 만료 시간을 짧게 두는 것이 피해를 줄이는 가장 확실한 방법입니다.

실무 점검 목록

배포 전에 다음 항목을 확인합니다.

  • payload에 비밀번호, 주민등록번호, 전체 전화번호 같은 개인정보가 들어 있지 않은가? 필요하면 사용자 내부 ID만 넣습니다.
  • 검증 코드가 허용 알고리즘을 고정하고 있는가? none이 통과되지 않는가?
  • exp를 밀리초와 혼동하지 않았는가? 시계 오차 허용 범위를 정했는가?
  • iss와 aud를 검사하는가? 다른 서비스가 발급한 토큰을 재사용할 수 없는가?
  • 토큰 저장 위치와 XSS, CSRF 대책이 함께 설계되어 있는가?
  • 로그에 토큰 전체를 남기지 않는가?

JWT는 구조가 단순해서 손으로 디코딩하기 쉽습니다. 그래서 디코딩 결과를 보고 "안전하다"고 판단하기 쉽습니다. 디코딩은 내용을 읽는 단계이고, 검증은 서명과 시간, 알고리즘을 함께 확인하는 단계입니다. 이 두 단계를 구분하면 토큰 관련 대부분의 실수를 피할 수 있습니다.

관련 도구