JWT는 왜 세 조각으로 나뉘고, 서버는 무엇을 검증하는가

seonwoo_jung·3일 전

1. 도입

로그인 기능을 만들다 보면 JWT를 "서버가 세션을 저장하지 않아도 되는 토큰" 정도로 설명하는 글을 자주 만난다. 틀린 말은 아니지만, 그 문장만으로는 실제 구현에서 어디까지 믿어도 되는지 애매하다. 특히 JWT를 디코딩해 보면 JSON처럼 읽히기 때문에, 처음에는 "안에 userId가 있으니 그냥 쓰면 되는 것 아닌가?"라는 착각을 하기 쉽다.

RFC 7519는 JWT(JSON Web Token)를 두 당사자 사이에서 claims를 JSON 객체로 안전하게 전달하기 위한 compact하고 URL-safe한 수단으로 정의한다. 여기서 중요한 단어는 "claims"와 "안전하게 전달"이다. JWT 자체가 사용자를 인증해 주는 마법의 문자열은 아니다. 서버는 토큰에 담긴 주장(claim)이 누가 만든 것인지, 아직 유효한지, 이 서비스에서 받아도 되는지 확인해야 한다.

JWT 검증은 payload를 읽는 일이 아니라, 그 payload를 믿어도 되는 조건을 하나씩 확인하는 일이다.

이 글에서는 JWT가 왜 header.payload.signature 세 조각으로 나뉘는지, 서버가 보통 어떤 순서로 검증하는지, 그리고 자주 헷갈리는 지점을 RFC 7519 기준으로 정리했다.

2. JWT의 핵심 개념

JWT는 점(.)으로 구분된 세 부분으로 표현되는 경우가 많다.

base64url(header).base64url(payload).base64url(signature)

첫 번째 조각은 JOSE Header다. 보통 토큰 타입과 서명 알고리즘이 들어간다. 예를 들어 typJWT이고 algHS256이면, 이 토큰은 JWT 형식이며 HMAC-SHA256 방식으로 서명되었다는 의미로 읽을 수 있다. 다만 이 값은 토큰 안에 들어 있는 데이터이므로, 검증 없이 무조건 신뢰하면 안 된다. 서버는 자신이 허용한 알고리즘 목록과 비교해야 한다.

두 번째 조각은 Claims Set이다. 흔히 payload라고 부르는 부분이다. RFC 7519는 claim을 크게 registered, public, private claim으로 나눈다. registered claim은 iss, sub, aud, exp, nbf, iat, jti처럼 미리 이름이 등록된 claim이다. 모두 필수는 아니지만, 인증 토큰으로 JWT를 쓸 때는 보통 만료 시각(exp), 발급자(iss), 대상자(aud)를 확인하는 흐름이 중요해진다.

세 번째 조각은 서명이다. 서명은 header와 payload를 base64url로 인코딩한 값을 이어 붙인 뒤, 지정된 알고리즘과 키로 계산한다. 서버가 같은 입력과 검증 키로 서명을 다시 확인했을 때 맞아야 토큰이 발급 이후 변조되지 않았다고 볼 수 있다.

헷갈리기 쉬운 점은 base64url 인코딩이 암호화가 아니라는 것이다. JWT의 header와 payload는 대부분 쉽게 디코딩해서 읽을 수 있다. 즉, JWT에 비밀번호나 주민등록번호 같은 민감정보를 넣으면 안 된다. 서명은 "못 읽게 하기"가 아니라 "바뀌지 않았음을 확인하기"에 가깝다. 내용을 숨겨야 한다면 JWE 같은 별도 방식이 필요하지만, 일반적인 인증 API에서 말하는 JWT는 JWS 형태의 서명된 토큰인 경우가 많다.

3. 서버의 검증 흐름

JWT 검증은 라이브러리가 처리해 주는 일이 많지만, 흐름을 알고 있어야 설정 실수를 줄일 수 있다. 일반적인 서버 검증은 다음 순서로 이해할 수 있다.

요청 Authorization 헤더
        |
        v
Bearer 토큰 추출
        |
        v
세 조각(header, payload, signature) 파싱
        |
        v
알고리즘과 키 선택
        |
        v
서명 검증
        |
        v
registered claims 검증(exp, nbf, iss, aud 등)
        |
        v
애플리케이션 권한 판단

첫 단계는 토큰을 가져오는 일이다. HTTP API에서는 보통 Authorization: Bearer <token> 형식을 사용한다. 이 단계에서 토큰이 없거나 점으로 세 조각이 나뉘지 않으면, 더 깊은 검증으로 들어가지 않고 인증 실패로 처리하는 편이 단순하다.

다음은 header를 확인하는 단계다. 여기서 alg를 읽고 검증 알고리즘을 정한다. 단, "토큰이 alg에 이렇게 써 두었으니 그대로 따른다"가 아니다. 서버 설정에 RS256만 허용, HS256만 허용처럼 허용 목록이 있어야 한다. 과거 JWT 취약점 사례 중에는 알고리즘 혼동이나 none 알고리즘 처리 실수처럼, header를 지나치게 믿어서 생긴 문제가 있었다고 알려져 있다. 그래서 실무에서는 라이브러리의 안전한 기본값과 명시적인 알고리즘 제한을 함께 확인하는 편이 좋다.

서명 검증은 핵심 단계다. HMAC 계열이라면 서버가 가진 shared secret으로 검증하고, RSA/ECDSA 계열이라면 발급자의 공개키로 검증한다. 이 검증이 실패하면 payload에 어떤 값이 들어 있든 믿을 수 없다. 공격자가 role: "admin"을 payload에 넣고 다시 base64url로 인코딩하는 것은 쉽지만, 올바른 키 없이 유효한 서명을 만들기는 어렵다는 가정에 기대는 구조다.

서명이 맞더라도 끝이 아니다. 그다음은 claim 검증이다. exp는 토큰 만료 시각이다. RFC 7519에 따르면 현재 시각이 exp보다 이전이어야 처리할 수 있고, 구현체는 clock skew를 고려해 약간의 leeway를 둘 수 있다고 설명한다. nbf는 not before, 즉 이 시각 전에는 토큰을 받아들이지 말라는 뜻이다. iss는 발급자, aud는 대상자를 나타낸다. 여러 인증 서버나 여러 API가 얽힌 환경에서는 issaud 검증이 특히 중요하다. "서명만 맞으면 됐다"로 끝내면, 다른 서비스용으로 발급된 토큰을 잘못 받아들이는 문제가 생길 수 있다.

마지막으로 애플리케이션 권한을 판단한다. JWT에 scope, roles, tenantId 같은 private claim을 넣는 경우가 많다. 이 값들은 표준이 아니라 서비스 내부 계약에 가깝다. 따라서 서버는 이 claim을 근거로 권한을 판단하되, 어떤 발급자가 어떤 claim을 넣는지 명확히 정해야 한다.

4. 작은 코드로 보는 검증 위치

아래 코드는 실제 라이브러리 구현을 대체하려는 예시가 아니다. 검증 단계가 어디에 놓이는지 보여 주기 위한 의사 코드에 가깝다. 실무에서는 직접 서명 검증을 구현하기보다 검증된 JWT 라이브러리를 쓰는 것이 안전하다.

public AuthPrincipal authenticate(String authorization) {
    String token = extractBearerToken(authorization);

    JwtHeader header = jwtParser.parseHeader(token);
    if (!allowedAlgorithms.contains(header.alg())) {
        throw new UnauthorizedException("unsupported alg");
    }

    Key key = keyResolver.resolve(header.kid(), header.alg());

    // 핵심: payload를 신뢰하기 전에 서명을 먼저 검증한다.
    JwtClaims claims = jwtVerifier.verifySignatureAndParseClaims(token, key);

    Instant now = clock.instant();
    if (claims.expiresAt().isBefore(now)) {
        throw new UnauthorizedException("expired token");
    }
    if (claims.notBefore() != null && claims.notBefore().isAfter(now)) {
        throw new UnauthorizedException("token not active yet");
    }
    if (!"https://auth.example.com".equals(claims.issuer())) {
        throw new UnauthorizedException("invalid issuer");
    }
    if (!claims.audience().contains("orders-api")) {
        throw new UnauthorizedException("invalid audience");
    }

    return new AuthPrincipal(claims.subject(), claims.scopes());
}

이 흐름에서 가장 중요한 경계는 "파싱"과 "검증"을 분리해서 생각하는 것이다. JWT 라이브러리는 보통 payload를 먼저 파싱할 수 있는 API와, 서명 및 claim을 함께 검증하는 API를 모두 제공한다. 디버깅이나 로깅 목적으로 파싱하는 것과 인증 결정을 내리는 것은 다르다. 인증 결정은 반드시 검증된 claim을 기준으로 해야 한다.

또 하나의 실수는 만료만 확인하고 발급자와 대상을 생략하는 것이다. 단일 인증 서버와 단일 API만 있는 작은 서비스에서는 문제가 드러나지 않을 수 있다. 하지만 서비스가 늘어나면 토큰의 "목적지"를 확인하는 일이 중요해진다. aud는 이 토큰이 어느 대상에게 쓰이도록 발급되었는지 나타내는 claim이고, iss는 누가 발급했는지 나타내는 claim이다. 둘을 확인하면 "우리 시스템의 신뢰할 수 있는 발급자가, 우리 API를 대상으로 발급한 토큰인가"라는 질문에 답할 수 있다.

5. 자주 헷갈리는 지점

첫째, JWT는 상태를 완전히 없애지 않는다. access token을 짧게 발급하고 refresh token을 별도로 관리한다면, refresh token 저장소나 폐기 목록 같은 상태가 여전히 필요할 수 있다. JWT access token 자체는 서버가 매 요청마다 세션 저장소를 조회하지 않도록 도와주지만, 로그아웃, 강제 만료, 권한 즉시 회수 같은 요구사항은 별도 설계를 필요로 한다.

둘째, sub는 사용자 ID처럼 쓰이지만 항상 같은 의미라고 단정하면 안 된다. RFC 7519에서 sub는 subject, 즉 claim의 주체를 나타내는 값이다. 어떤 시스템에서는 사용자 ID일 수 있고, 어떤 시스템에서는 클라이언트나 서비스 계정일 수 있다. 서비스 내부에서 sub의 의미를 어떻게 정의하는지 문서화해야 한다.

셋째, JWT 크기는 공짜가 아니다. claim을 많이 넣으면 매 요청 헤더가 커진다. HTTP 헤더 크기 제한이나 네트워크 비용을 고려하면, JWT에는 인증과 권한 판단에 필요한 최소한의 정보만 넣는 편이 낫다. 상세 사용자 프로필처럼 자주 바뀌거나 큰 데이터는 토큰 대신 서버 조회로 분리하는 방식이 더 단순할 때가 많다.

넷째, 서명 키 관리가 토큰 보안의 핵심이다. HMAC secret이 유출되면 공격자가 유효한 토큰을 만들 수 있고, RSA private key가 유출되어도 마찬가지다. 반대로 RSA 공개키는 검증용으로 배포할 수 있기 때문에 여러 서비스가 같은 발급자의 토큰을 검증하는 구조에 적합하다. 키 회전을 고려한다면 header의 kid를 이용해 어떤 키로 검증할지 선택하는 패턴을 자주 쓴다. 다만 kid 역시 토큰 안의 값이므로, 서버가 신뢰하는 키 저장소 안에서만 매칭해야 한다.

6. 정리

JWT는 header.payload.signature라는 작은 문자열 안에 "어떤 알고리즘으로 서명했는가", "어떤 claim을 전달하는가", "정말 발급자가 만든 값인가"를 함께 담는다. 하지만 서버가 해야 할 일은 단순 디코딩이 아니다. 허용된 알고리즘인지 확인하고, 올바른 키로 서명을 검증하고, exp, nbf, iss, aud 같은 claim을 현재 서비스의 규칙과 비교해야 한다.

내가 JWT를 다시 공부하면서 가장 크게 정리한 지점은 이것이다. payload는 읽을 수 있지만, 읽힌다고 해서 곧바로 믿을 수 있는 것은 아니다. JWT 기반 인증의 안전성은 토큰 형식보다 검증 정책에서 결정된다.

다음에 더 파고들 만한 주제로는 JWS와 JWE의 차이, 그리고 access token과 refresh token을 함께 쓸 때의 폐기 전략이 있다. 특히 로그아웃과 강제 권한 회수가 필요한 서비스라면 "JWT라서 stateless"라는 설명만으로는 부족하고, 별도의 상태 관리 경계를 같이 설계해야 한다.

참고 자료

  • RFC 7519: JSON Web Token (JWT)

0개의 댓글