개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › 보안 › 인증

JWT의 구조와 검증

누구나 읽을 수 있는 토큰을 서버는 어떻게 믿나

JWT는 점(.)으로 이어진 세 부분, 곧 머리말과 내용과 서명으로 된 글자입니다. 앞의 두 부분은 암호화한 것이 아니라 표기만 바꿔 적은 것이라 누구나 읽고 고칠 수 있습니다. 서버가 그래도 이 토큰을 믿을 수 있는 까닭은 셋째 부분인 서명을 직접 다시 계산해 보기 때문입니다.

실행으로 대조 · Node.js 24의 crypto(HMAC-SHA256)로 토큰을 실제로 만들어 열여섯 가지 조합의 검증 결과를 대조했고, 화면의 자바 코드도 JDK 21에서 실행해 대조

이 실습에 나오는 개념

JWT(JSON Web Token)
사용자 정보 등의 내용(클레임)을 담고 서명을 붙인 토큰이다. 헤더, 페이로드, 서명의 세 부분으로 이뤄진다. 서버는 서명을 검증해 토큰이 위조되지 않았음을 확인하며, 별도의 저장소를 조회하지 않아도 된다. 페이로드는 암호화된 것이 아니라 인코딩된 것이어서 누구나 내용을 볼 수 있으므로 민감한 정보를 넣지 않는다.
Base64URL 인코딩
바이트를 영문 대소문자와 숫자, 그리고 -와 _만으로 옮겨 적는 표기법이다. 주소(URL)나 HTTP 헤더에 그대로 넣어도 깨지지 않도록 Base64에서 +와 /를 -와 _로 바꾸고 끝의 =를 뺀 것이다. JWT의 세 부분은 모두 이 표기로 적혀 있다. 키가 없어도 누구나 원래 내용으로 되돌릴 수 있으므로 암호화가 아니며, 내용을 숨기는 효과는 전혀 없다.
클레임(Claim)
토큰의 내용(payload)에 적힌 "이름: 값" 한 쌍을 가리키는 말이다. 누구에 관한 토큰인지를 적는 sub, 만료 시각을 적는 exp, 발급한 곳을 적는 iss처럼 표준이 이름을 정해 둔 것이 있고, role처럼 서비스가 필요해서 넣는 것도 있다. exp는 1970년 1월 1일 0시(UTC)부터 센 초로 적으며, 서버는 지금 시각이 exp보다 앞일 때만 토큰을 받는다. 클레임은 누구나 읽을 수 있으므로 비밀번호 같은 민감한 값은 넣지 않고, 서명을 확인하기 전에는 그 내용을 믿지 않는다.
HMAC과 전자서명
메시지가 중간에 바뀌지 않았고 정당한 상대가 보냈음을 확인하는 기술이다. HMAC은 양쪽이 공유한 비밀키와 메시지를 함께 해시해 인증 코드를 만든다. 전자서명은 보내는 쪽이 개인키로 서명하고 받는 쪽이 공개키로 검증하며, 서명한 쪽만 만들 수 있으므로 보낸 사실을 부인할 수 없다.
알고리즘 고정(alg 검증)
토큰을 검증할 때 쓸 서명 알고리즘을 토큰이 아니라 서버가 정해 두는 것이다. JWT의 머리말에는 alg라는 항목이 있어 어떤 알고리즘으로 서명했는지 적혀 있지만, 머리말은 누구나 고칠 수 있는 글자다. 서버가 이 값을 그대로 따르면, 보내는 쪽이 alg를 none(서명 없음)으로 바꿔 서명 확인을 건너뛰게 만들 수 있다. 그래서 서버는 받을 알고리즘의 목록을 미리 정하고, 머리말의 alg가 그 목록에 없으면 서명을 계산해 보기도 전에 거절한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 누군가 토큰의 가운데 부분을 고쳐서 보냈습니다. role을 USER에서 ADMIN으로 바꿔 다시 적었고, 끝의 서명은 원래 것을 그대로 두었습니다.
  2. token.split("\\."): 서버가 토큰을 점(.)에서 잘라 세 부분으로 나눕니다. 첫째가 머리말(header), 둘째가 내용(payload), 셋째가 서명(signature)입니다.
  3. dec.decode(part[0]): 첫째 부분을 base64url 디코더로 풉니다. 키는 필요 없습니다. 결과는 {"alg":"HS256","typ":"JWT"} 입니다. alg는 "이 토큰은 이 알고리즘으로 서명했다"는 토큰 쪽의 주장입니다.
  4. dec.decode(part[1]): 둘째 부분도 같은 방법으로 풉니다. 결과는 {"sub":"7","role":"ADMIN","exp":1767225600} 입니다. 암호화한 것이 아니라 표기만 바꿔 적은 것이라, 토큰을 손에 넣은 사람은 누구나 읽습니다. 아직 서명을 확인하지 않았으므로 서버는 이 내용을 믿지 않습니다.
  5. 알고리즘 확인: 이 서버는 HS256만 받기로 정해 두었습니다. 머리말의 alg가 HS256이므로 다음 확인으로 넘어갑니다.
  6. mac.doFinal(part[0] + "." + part[1]): 받은 토큰의 앞 두 부분과 서버만 아는 키로 서명을 다시 계산합니다. 결과는 _Crl3waj… 입니다. 토큰에 붙어 온 서명을 그대로 믿지 않고, 서버가 직접 만들어 보는 것입니다.
  7. MessageDigest.isEqual: 다시 계산한 값(_Crl3waj…)과 토큰의 셋째 부분(5hho4Bt2…)을 비교합니다. 다릅니다. 붙어 온 서명은 고치기 전의 내용으로 만든 것이라, 고친 내용으로 계산한 값과 맞지 않습니다. "서명 불일치"로 거절합니다.
  8. 거절: 서버는 토큰의 내용을 쓰지 않고 요청을 거절합니다. 보통 401 Unauthorized로 답합니다.

실행 결과

코드

// 요청 헤더  Authorization: Bearer <토큰>
// 토큰  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
//       .eyJzdWIiOiI3Iiwicm9sZSI6IkFETUlOIiwiZXhwIjoxNzY3MjI1NjAwfQ
//       .5hho4Bt2LLsypoE43dqORdbPqUDkMlzinRCY-zDKmMc
// 지금  now = 1767225000   (exp = 1767225600)

Claims verify(String token, long now) throws Exception {
    String[] part = token.split("\\.", -1);                 // 머리말 . 내용 . 서명
    if (part.length != 3) throw new BadTokenException("형식 오류");
    Base64.Decoder dec = Base64.getUrlDecoder();
    String header  = new String(dec.decode(part[0]), UTF_8);   // 키 없이 풀린다
    String payload = new String(dec.decode(part[1]), UTF_8);   // 키 없이 풀린다

    // 1. 알고리즘: 서버가 정해 둔 HS256만 받는다
    if (!"HS256".equals(field(header, "alg")))
        throw new BadTokenException("허용하지 않는 알고리즘");

    // 2. 서명: 앞의 두 부분으로 서버가 직접 다시 계산해 비교한다
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(serverKey, "HmacSHA256"));  // 서버만 아는 키
    byte[] expected = mac.doFinal((part[0] + "." + part[1]).getBytes(US_ASCII));
    if (!MessageDigest.isEqual(expected, dec.decode(part[2])))
        throw new BadTokenException("서명 불일치");

    // 3. 만료: 지금이 exp보다 앞이어야 한다
    if (now >= Long.parseLong(field(payload, "exp")))
        throw new BadTokenException("만료됨");
    return new Claims(field(payload, "sub"), field(payload, "role"));   // 여기까지 와야 내용을 믿는다
}

// field(json, 이름)은 JSON에서 값 하나를 꺼내는 도우미다.
// 원리를 보이려고 직접 적은 코드다. 실제 서비스에서는 검증된 JWT 라이브러리를 쓴다.

이 모형의 전제

화면의 토큰 글자, 서명 값, 열여섯 가지 조합의 검증 결과는 이 PC의 Node.js 24에 든 crypto(HMAC-SHA256)로 토큰을 실제로 만들고 검증해 본 결과와 대조했습니다. 화면의 자바 코드도 JDK 21에서 그대로 실행해 같은 토큰과 같은 결과가 나오는 것을 확인했습니다. 이 PC에는 JWT 라이브러리가 없어서, 검증 절차는 표준 문서를 보고 직접 적은 코드로 실행했습니다. 근거는 다음과 같습니다. 세 부분을 점으로 잇는 모양과 base64url 표기는 RFC 7515 3절과 2절, 서명을 계산하는 대상이 "첫째 부분.둘째 부분"이라는 것은 RFC 7515 5.1절, 검증 순서는 RFC 7515 5.2절과 RFC 7519 7.2절, 지금이 exp보다 앞이어야 한다는 것은 RFC 7519 4.1.4절, alg가 none이고 서명이 빈 토큰의 모양은 RFC 7519 6.1절입니다. 지금이 exp와 정확히 같을 때는 거절됩니다(직접 실행해 확인). 서버가 받을 알고리즘을 정해 두어야 한다는 것은 RFC 8725 3.1절의 권고이고, 널리 쓰이는 JWT 라이브러리가 none을 기본으로 거절한다는 것은 실행으로 확인하지 못했습니다. 실제 서버는 시계 오차를 감안해 exp에 몇 초에서 몇 분의 여유를 두기도 하고(RFC 7519 4.1.4절이 허용), iss, aud 같은 다른 클레임도 함께 확인합니다. 화면의 키와 토큰은 이 실습을 위해 만든 값입니다. 공개키 방식(RS256)을 쓰는 서버에서 alg를 HS256으로 바꿔 보내는 문제는 다루지 않았습니다.

앞 실습: 비밀번호 해시 · 다음 실습: OAuth 로그인 흐름

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요