JWT는 안전하지 않다 — JWS와 JWE를 이해하기 전까지

발행: (2025년 12월 28일 오후 01:19 GMT+9)
8 분 소요
원문: Dev.to

Source: Dev.to

위의 링크에 있는 전체 글을 번역하려면, 번역하고자 하는 텍스트(본문)를 제공해 주세요.
코드 블록이나 URL은 그대로 유지하고, 본문만 한국어로 번역해 드리겠습니다.

Introduction

JWT는 기본적으로 안전하지 않습니다. 빠르고, 무상태이며, 강력하지만 실제로 무엇을 하고 있는지 이해하지 못하면 쉽게 깨질 수 있습니다. 대부분의 개발자는 단순히 jwt.verify(token, secret)를 복사‑붙여넣기 하고 넘어갑니다—이것이 문제의 시작점입니다.

JWT 기본

JWTJSON Web Token의 약자입니다. JOSE(JSON Object Signing and Encryption)의 일부입니다. 두 가지 개념이 중요합니다:

  • JWS (JSON Web Signature) – 데이터를 서명합니다. 누구나 내용을 읽을 수 있지만, 변조 여부를 감지할 수 있습니다.
  • JWE (JSON Web Encryption) – 데이터를 암호화합니다. 의도된 수신자만 내용을 읽을 수 있습니다.

대부분의 JWT는 JWS입니다. 페이로드를 숨길 필요가 거의 없고, 단지 변조되지 않았다는 증명이 필요하기 때문입니다.

토큰 구조

eyJhbGci... . eyJzdWIi... . 2Xh3n...
│ header │ │ payload │ │ signature │

각 부분은 base64url‑인코딩된 JSON입니다:

부분내용
HEADER{ "alg": "HS256" }
PAYLOAD{ "sub": "user_123" }
SIGNATUREHMAC(header + payload)

시각적 구분:

┌─────────────┐
│   HEADER    │ → 알고리즘 + 메타데이터
├─────────────┤
│   PAYLOAD   │ → 당신의 클레임 (읽을 수 있음!)
├─────────────┤
│  SIGNATURE  │ → 무결성 증명
└─────────────┘

페이로드는 암호화되지 않았습니다. Base64는 인코딩일 뿐이며, 암호화가 아니므로 누구나 디코딩할 수 있습니다:

atob("eyJzdWIiOiJ1c2VyXzEyMyJ9"); // {"sub":"user_123"}

헤더 세부 정보

{
  "alg": "HS256",
  "kid": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
  • alg – 서명에 사용된 알고리즘 (예: HS256, RS256, ES256).
  • kid – 토큰에 서명한 키의 식별자 (UUID만; 파일 경로나 URL은 절대 사용하지 않음).
  • jku – 키를 가져올 URL (토큰에서 이 값을 절대 신뢰하지 말 것).

규칙: 헤더 값에 기반해 보안 결정을 내리지 말 것; 공격자가 이를 제어할 수 있다.

페이로드 클레임

{
  "sub": "user_5839",
  "exp": 1640995200,
  "iss": "https://auth.yourapp.com",
  "aud": "https://api.yourapp.com",
  "jti": "a1b2c3d4"
}
  • sub – 주체 (사용자 식별자).
  • exp – 만료 시간 (필수).
  • iss – 발행자.
  • aud – 대상.
  • jti – 고유 토큰 ID (로그아웃/블랙리스트에 유용).

절대 JWT에 비밀(비밀번호, API 키, 주민등록번호 등)을 저장하지 마세요.

JWE (암호화)

기밀성이 필요할 때, JWE는 페이로드를 암호화합니다:

header.encrypted_key.iv.ciphertext.tag

JWE를 사용해야 할 때

  • 토큰이 신뢰할 수 없는 중개자를 거칩니다.
  • 규정 준수를 위해 데이터가 저장될 때 암호화가 필요합니다.

JWE를 생략해야 할 때

  • 통신이 이미 HTTPS를 사용하고 있습니다(전송 중 암호화).
  • 발행자와 검증자가 모두 귀하의 관리 하에 있습니다.

대부분의 시스템은 JWE가 필요하지 않습니다.

일반적인 함정

알고리즘 혼동

// Vulnerable: algorithm not enforced
const decoded = jwt.verify(token, publicKey);

토큰에 "alg": "HS256"이라고 명시되어 있지만 서버가 RS256을 기대한다면, 공격자는 토큰을 위조할 수 있습니다.

none 알고리즘

"alg": "none"인 토큰은 서명이 없습니다. 일부 라이브러리는 이를 실수로 허용합니다:

eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.

검증되지 않은 kid

{ "kid": "../../etc/passwd" }
{ "kid": "http://attacker.com/keys" }

kid 값을 기반으로 직접 키를 로드하면 검증이 없을 경우 임의 파일 읽기 또는 원격 키 삽입으로 이어질 수 있습니다.

신뢰할 수 없는 jku

서버가 토큰에 포함된 URL(jku)에서 키를 가져오도록 설정되어 있다면, 공격자는 이를 악성 소스로 지정할 수 있습니다.

검증 모범 사례

jwt.verify(token, key, {
  algorithms: ['RS256'],                     // 허용된 알고리즘을 하드코딩
  issuer: 'https://auth.yourapp.com',        // 기대하는 발행자를 하드코딩
  audience: 'https://api.yourapp.com',      // 기대하는 대상을 하드코딩
  maxAge: '30m'                              // 토큰 유효 기간을 강제
});
  • 허용된 알고리즘을 명시적으로 설정합니다.
  • iss(발행자)와 aud(대상)를 검증합니다.
  • 토큰에 포함된 jku를 절대 신뢰하지 말고, 서버 측에서 JWKS URL을 구성합니다.
  • kid에는 파일 경로나 URL이 아닌 UUID를 사용합니다.
  • 액세스 토큰은 짧게 유지합니다 (15–30 분).
  • 리프레시 토큰은 폐기 지원을 포함한 더 긴 기간으로 사용합니다.

요약

JWT는 서명된 진술입니다. 서명은 무결성과 진위성을 보장하지만, 토큰이 받아들여져야 한다는 것을 보장하지 않습니다. 토큰을 받아들일지는 클레임(exp, iss, aud 등)의 적절한 검증에 달려 있습니다.

대부분의 JWT 취약점은 다음에서 발생합니다:

  • 신뢰해서는 안 되는 입력을 신뢰하는 경우(예: 헤더 필드).
  • 필수 검증 단계를 건너뛰는 경우.

검증을 엄격히 설정하고, 모든 것을 검증하며, 라이브러리가 보안을 자동으로 적용한다고 가정하지 마세요. 서명이 실제로 증명하는 바를 이해하는 것이 안전한 구현과 깨진 구현을 가르는 차이점입니다.

참고 문헌

  • RFC 7515 – JSON 웹 서명 (JWS)
  • RFC 7516 – JSON 웹 암호화 (JWE)
  • RFC 7519 – JSON 웹 토큰 (JWT)
Back to Blog

관련 글

더 보기 »