앞선 글에서 HS256과 RS256의 선택 기준을 정리했다. HS256은 같은 비밀키로 서명과 검증을 하고, RS256은 개인키로 서명하고 공개키로 검증한다는 내용이다.
그다음에는 이런 질문이 남는다.
토큰 문자열을 받았을 때, 서버는 정확히 무엇에 키를 적용하고 무엇을 비교할까?
이 글은 JWT 중에서도 서명이 들어간 형태인 JWS Compact Serialization을 기준으로 과정을 따라간다. 사용하는 라이브러리마다 API 이름은 달라도, 안전하게 확인하는 순서는 같다.
JWT는 세 부분으로 나뉜다
서명된 JWT는 보통 다음 형태다.
base64url(protected header).base64url(payload).base64url(signature)
예를 들어 사람이 읽기 쉬운 원본 값이 아래와 같다고 하자.
// protected header
{"alg":"RS256","kid":"key-2026-08","typ":"JWT"}
// payload
{"sub":"joon","iss":"https://auth.example.com","exp":1780000000}
서명에 넣는 값은 이 JSON 두 개를 그냥 이어 붙인 문자열이 아니다. JWS 규격은 UTF-8 바이트를 base64url로 인코딩한 header와 payload를 점(.)으로 이어 붙인 ASCII 바이트열을 signing input으로 정의한다.
signingInput =
base64url(UTF-8(protected header)) + "." + base64url(payload)
즉 실제 토큰의 첫 번째·두 번째 조각을 그대로 .으로 연결한 값이다. 이 정의는 JWS(RFC 7515) 2절과 JWS Compact Serialization에 명시되어 있다.
받은 문자열을 그대로 확인해야 하는 이유
아래 두 JSON은 의미상 같은 객체처럼 보인다.
{"sub":"joon","role":"admin"}
{ "role": "admin", "sub": "joon" }
하지만 공백과 key 순서가 달라 바이트열은 다르다. base64url 결과도 달라지고, 당연히 서명도 달라진다.
그래서 검증 서버는 payload를 JSON으로 파싱한 뒤 다시 직렬화해서 서명 대상을 만들면 안 된다. 수신한 토큰의 원래 첫 번째 조각과 두 번째 조각을 .으로 이어 서명 입력값으로 사용해야 한다. JSON 파싱은 서명이 성공한 뒤 claim을 읽기 위해 한다.
base64url은 URL과 HTTP 헤더에 넣기 편하도록 +, /, = 대신 URL-safe 표현을 쓰는 Base64 변형이다. JWS Compact Serialization에서는 padding =도 생략한다. RFC 7515의 base64url 정의에서 확인할 수 있다.
JWT에 서명하는 과정
JWT 발급 서버가 하는 일은 크게 세 단계다.
- protected header와 payload를 원하는 내용으로 만든다.
- 두 값을 base64url 인코딩해 signing input을 만든다.
- 알고리즘에 맞는 키로 signing input을 처리해 signature를 만든다.
세 번째 단계가 HS256과 RS256에서 달라진다.
HS256은 비밀키 하나를 함께 쓴다
HS256은 공유 비밀키 K로 HMAC을 계산한다.
signature = HMAC-SHA-256(K, ASCII(signingInput))
검증 서버도 K를 알고 있으므로 똑같은 signing input으로 HMAC을 다시 계산할 수 있다. 두 결과가 같으면 토큰의 첫 두 조각이 바뀌지 않았고, 공유 비밀키를 가진 서버가 만든 토큰이라고 판단한다.
Java 표준 API로 핵심만 표현하면 아래와 비슷하다. 운영 환경에서는 비밀키를 코드에 넣지 않는다. Kubernetes Secret, 클라우드 Secret Manager처럼 접근 제어와 감사 로그를 제공하는 곳에 두고, 애플리케이션에는 배포 시점에 주입한다.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
byte[] signingInput = encodedHeader + "." + encodedPayload
.getBytes(StandardCharsets.US_ASCII);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(sharedSecret, "HmacSHA256"));
byte[] expectedSignature = mac.doFinal(signingInput);
// 받은 세 번째 조각을 base64url decode한 값과 시간 상수 비교한다.
boolean valid = MessageDigest.isEqual(expectedSignature, receivedSignature);
위 예제에서 encodedHeader와 encodedPayload는 이미 base64url 인코딩된 문자열이다. JWA는 HS256에 SHA-256 출력 크기와 같은 256비트 이상 키를 요구하며, 비교는 타이밍 공격을 피하기 위해 constant-time 방식으로 해야 한다고 명시한다. 자세한 내용은 RFC 7518의 HS256 절을 참고하면 된다.
RS256은 개인키와 공개키를 나눠 쓴다
RS256은 발급 서버의 RSA 개인키로 signing input에 서명한다. 검증 서버는 짝이 되는 공개키로 그 서명이 해당 개인키에서 나온 것인지 확인한다.
signature = RSASSA-PKCS1-v1_5-SIGN(privateKey, SHA-256, signingInput)
valid = RSASSA-PKCS1-v1_5-VERIFY(publicKey, SHA-256, signingInput, signature)
여기서 SHA-256은 signing input을 고정 길이 digest로 만드는 데 쓰이고, RSA 서명 규칙은 그 digest의 형식과 RSA 연산을 정의한다. RS256은 RSA로 “SHA-256 hash를 암호화한다” 정도로 설명하면 중요한 padding 규칙이 빠진다. 정확히는 RSASSA-PKCS1-v1_5 서명 규칙과 SHA-256의 조합이다. 이 알고리즘 정의는 RFC 7518의 RS256 절, RSA 서명·검증 절차는 PKCS #1(RFC 8017) 8.2절에 있다.
Java에서는 Signature가 이 과정을 담당한다.
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
byte[] signingInput = (encodedHeader + "." + encodedPayload)
.getBytes(StandardCharsets.US_ASCII);
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(signingInput);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(signingInput);
boolean valid = verifier.verify(receivedSignature);
이 코드는 JWT 라이브러리를 직접 대체하려는 목적은 아니다. 표준 API가 보여 주듯 RS256 검증에는 개인키가 필요 없고 공개키만 필요하다는 점을 이해하기 위한 예시다. 운영 코드에서는 충분히 검증된 JWT 라이브러리를 사용하고, 허용 알고리즘과 claim 검증 규칙을 명시적으로 설정하는 편이 낫다.
JWT를 검증하는 순서
토큰을 받자마자 payload.role == "admin"부터 읽는 것은 위험하다. signature가 확인되기 전 payload는 클라이언트가 바꿔 보낸 문자열일 뿐이다.
검증 서버는 보통 아래 순서로 처리한다.
| 순서 | 하는 일 | 이유 |
|---|---|---|
| 1 | 토큰 길이와 세 조각 형식을 확인한다 | 비정상 입력을 초기에 거절한다. |
| 2 | protected header를 읽고 허용한 alg인지 확인한다 | 공격자가 header의 알고리즘을 바꿔 검증 정책을 우회하지 못하게 한다. |
| 3 | 신뢰하도록 설정한 키 목록에서 kid에 맞는 키를 고른다 | kid는 키를 고르기 위한 힌트일 뿐, 이 값만으로 키를 신뢰하면 안 된다. |
| 4 | 원래의 encodedHeader + "." + encodedPayload로 signature를 검증한다 | 서명된 바이트열이 바뀌지 않았는지 확인한다. |
| 5 | exp, nbf, iss, aud, 권한 claim을 검증한다 | 유효한 서명과 이 요청의 권한은 다른 문제다. |
alg는 서버가 허용 목록으로 정한다
header에 {"alg":"RS256"}이라고 쓰여 있어도, 그것만으로 검증 서버가 RS256을 써야 하는 것은 아니다. 공격자가 header만 HS256이나 none으로 바꿔 보낼 수 있기 때문이다.
서버는 “이 issuer의 access token은 RS256만 허용한다”처럼 알고리즘을 설정으로 고정해야 한다. 그다음 수신한 alg가 그 정책과 정확히 같은지 확인한다. JWT Best Current Practices도 검증 시 허용 알고리즘을 명시적으로 검증하라고 권고한다. RFC 8725 3.1절을 참고할 수 있다.
특히 HS256과 RS256을 같은 검증 경로에서 섞을 때는 키 타입까지 함께 고정해야 한다. RSA 공개키 바이트를 HMAC 비밀키처럼 취급해 다른 알고리즘으로 다시 검증하는 구현은 피해야 한다.
kid로 검증에 쓸 키를 찾는다
키를 교체하다 보면 한동안 공개키가 여러 개 존재한다. 이때 header의 kid(key ID)는 “이 토큰은 이 라벨의 키로 서명했다”는 키 선택 힌트로 쓰인다.
{"alg":"RS256","kid":"key-2026-08"}
API 서버는 미리 신뢰하도록 설정한 issuer의 JWKS(JSON Web Key Set)에서 kid=key-2026-08 공개키를 찾는다. kid가 있다고 해서 누구의 키든 가져와 검증하면 안 된다. 토큰 header에 들어 있는 임의 URL을 따라가거나, 공격자가 지정한 JWKS를 신뢰하면 키 선택 단계 자체가 공격 표면이 된다.
공개키 교체는 보통 이렇게 진행한다.
- 인증 서버의 JWKS에 새 공개키
B를 기존 공개키A와 함께 올린다. - 검증 서버가
B를 받아 캐시할 시간을 준다. - 새 토큰부터
kid=B와 새 개인키로 서명한다. A로 서명된 토큰이 모두 만료된 뒤A를 JWKS에서 제거한다.
kid와 JWK Set의 구조는 JSON Web Key(RFC 7517)에 정의되어 있다.
서명이 맞아도 검증은 끝나지 않는다
서명이 유효하다는 것은 다음 정도만 말해 준다.
이 signing input은 선택한 키에 대응하는 서명과 맞는다.
하지만 애플리케이션은 여전히 이 토큰이 지금, 이 API를 위해, 이 권한으로 쓰여도 되는지 판단해야 한다.
signature 검증 성공
→ 만료되지 않았는가? (exp, nbf)
→ 기대한 인증 서버가 발급했는가? (iss)
→ 이 서비스가 대상인가? (aud)
→ 이 API 작업에 필요한 권한이 있는가? (scope, role 등)
예를 들어 어제 발급된 토큰이 개인키로 올바르게 서명됐더라도 exp가 지났다면 거절해야 한다. 다른 서비스용 토큰이라면 aud가 맞지 않아 거절해야 한다. signature 검증은 이 claim 검증의 전제 조건이지, 대체재가 아니다.
구현할 때 기억할 세 가지
- 수신한 인코딩 조각을 그대로 서명 검증에 쓴다. JSON을 다시 직렬화하지 않는다.
- 알고리즘·키 타입·issuer를 검증 서버 설정으로 고정한다. 토큰 header는 키 선택 힌트일 뿐이다.
- 서명 검증 뒤에 claim을 검증한다.
exp,iss,aud, 권한을 모두 확인해야 한다.
키가 서명에 쓰이는 모습을 따라가 보면 HS256과 RS256의 차이도 다시 선명해진다. HS256 검증자는 HMAC을 재계산할 비밀키가 필요하므로 서명도 만들 수 있다. RS256 검증자는 공개키로 서명의 진위만 확인하고, 개인키 없이 새 토큰을 만들 수는 없다.