JWT invalid signature 오류: 모든 원인과 해결 방법
JWT의 invalid signature 오류가 뜻하는 것은 정확히 하나입니다. 검증 측이 계산해 낸 서명이 토큰에 실려 온 서명과 다르다는 것입니다. 메시지가 담고 있는 정보는 그게 전부입니다. 토큰이 만료됐다거나 사용자에게 권한이 없다는 뜻은 아닙니다. JWT 라이브러리가 고장 난 것도 아닙니다. HMAC에 들어가는 바이트, 또는 검증 호출에 들어가는 공개 키가 서명한 쪽과 확인하는 쪽 사이에서 어딘가 어긋나 있는 것입니다.
열에 아홉은 원인이 토큰이 아니라 키 쪽에 있습니다. 아래 분기표로 출발점을 고르세요:
헤더의 알고리즘은 무엇입니까?
├─ HS256 / HS384 / HS512 → 거의 언제나 비밀 키 문제
│ ├─ 서명 측과 검증 측의 언어가 다른가? → 3절
│ └─ 같은 언어인데 로컬은 되고 운영만 실패하나? → 4절
└─ RS256 / ES256 / PS256 → 거의 언제나 키 형식 또는 잘못 고른 키
└─ → 7절
토큰이 게이트웨이나 프록시를 거쳤거나, 복사해 붙여 넣었다? → 6절
몇 시간 뒤에만, 또는 특정 호스트에서만 오류가 난다? → 8절
가장 빠른 첫 수는 토큰을 JWT 디코더에 붙여 넣고 alg 필드를 읽는 것입니다. 그 값 하나만 알아도 위 분기의 절반은 그 자리에서 사라집니다.
1. invalid signature가 실제로 뜻하는 것
라이브러리마다 같은 실패를 두고 서로 다른 문자열을 출력합니다. 아래 목록에서 본인 것을 찾아, 제대로 된 안내서를 보고 있는지 확인하세요:
- Node
jsonwebtoken:JsonWebTokenError: invalid signature - Python
PyJWT:InvalidSignatureError: Signature verification failed - Java
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
셋 다 같은 코드 경로의 같은 순간에 발생합니다. 라이브러리는 토큰의 앞 두 조각을 가져와 여러분이 건넨 키로 서명을 다시 계산하고, 그 결과를 세 번째 조각과 바이트 단위로 비교합니다. 같지 않으면 예외를 던집니다.
이 비교는 정확 일치이며, 두 값이 얼마나 다른지에 대한 정보는 전혀 담고 있지 않습니다. 비밀 키가 1바이트 어긋난 경우와 완전히 엉뚱한 키를 쓴 경우가 똑같은 오류 메시지를 냅니다. 그래서 오류 문구를 아무리 꼼꼼히 읽어도 더 나올 것이 없고, 대신 입력 쪽 후보를 하나씩 지워 나가야 합니다.
이 오류가 났을 때 아직 일어나지 않은 일도 함께 봐 두세요. 클레임 검증은 서명 검증 다음에 실행되므로 exp, nbf, aud, iss는 아직 들여다보지도 않은 상태입니다. JWT 서명 검증이 실패했다면 토큰의 내용은 진단과 무관합니다. 물론 JWT는 암호화된 것이 아니라 인코딩된 것이므로 내용 자체는 여전히 읽을 수 있습니다. 헤더와 페이로드를 디코딩하는 데는 키가 전혀 필요 없습니다. 조각 단위로 따라가는 설명은 JWT 토큰 디코딩 방법을 참고하세요.
다음 행선지를 정하는 것은 헤더의 두 필드입니다. alg는 공유 비밀 키를 쫓아야 하는지 키 쌍을 쫓아야 하는지 알려 주고, kid는 서명 측이 어떤 키를 쓴다고 믿었는지 알려 줍니다.
2. 서명이 덮는 것은 객체가 아니라 인코딩된 문자열입니다
대부분의 개발자가 여기를 거꾸로 알고 있습니다. JSON Web Signature 명세인 RFC 7515는 JWS Signing Input을 다음 ASCII 문자열로 정의합니다:
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
HMAC은 그 문자열 위에서 계산됩니다. 여러분의 claims 맵이나 JSON 객체처럼 언어가 구조화된 데이터로 다루는 것은 대상이 아닙니다. 아래는 이 글 전체에서 사용하는 signing input이며, 표준 예제 페이로드에서 가져온 것입니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
여기서 규칙 하나가 따라 나오고, 많은 팀이 여기에 걸립니다. 페이로드를 디코딩했다가 다시 인코딩하는 계층은 어느 것이든 서명을 파괴합니다. JSON 직렬화는 정규적(canonical)이지 않습니다. 대부분의 언어에서 맵이 한 바퀴 돌아 나오면 키 순서가 달라집니다. 공백 문자가 생기거나 사라집니다. 비-ASCII 문자를 어떤 직렬화기는 \uXXXX로 이스케이프하고, 어떤 직렬화기는 그대로 내보냅니다. 숫자는 형식이 다시 매겨져 1516239022가 1516239022.0으로 돌아오기도 합니다. 이 가운데 무엇이든 base64url 문자열을 바꿔 놓고, 그러면 signing input이 바뀌고 서명도 바뀝니다.
실제로 목격한 유발 지점:
- JWT를 파싱해 테넌트 ID를 덧붙인 뒤 토큰을 다시 내보내는 API 게이트웨이.
- 헤더를 “정규화”하면서 Authorization 값을 다시 쓰는 로깅 또는 트레이싱 미들웨어.
- 토큰을 읽으려고 예쁘게 정렬해 두었다가, 그 정렬된 버전을 그대로 다시 붙여 넣은 개발자.
서명 측과 검증 측 사이에 토큰을 다시 쓸 수 있는 구성 요소가 있다면, 그 구성 요소가 1순위 용의자입니다. 전송 중의 토큰은 불투명한 문자열입니다. 안전한 연산은 저장, 복사, 비교뿐입니다.
3. 같은 비밀 키, 다른 바이트
“비밀 키가 글자 하나까지 똑같습니다, diff도 떠 봤습니다”로 시작하는 버그 리포트는 대개 여기서 걸립니다.
HMAC은 문자열을 받지 않습니다. 바이트를 받습니다. 설정 파일도, 시크릿 매니저도, 환경 변수도 전부 문자열을 저장합니다. 어딘가에서 하나를 다른 하나로 변환해야 하는데, 그 변환은 JWT 라이브러리들 사이에서 표준화돼 있지 않습니다. 두 서비스가 글자 하나까지 동일한 비밀 키를 들고 있으면서도 서로 다른 서명을 계산할 수 있습니다.
증거는 다음과 같습니다. 2절의 signing input을 대상으로 로컬에서 계산한 값입니다. 비밀 키 문자열은 36자입니다:
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| 바이트 해석 방식 | 바이트 수 | 키의 실제 내용 | 계산된 HS256 서명 |
|---|---|---|---|
| UTF-8 텍스트로 취급 | 36 | 눈에 보이는 그 36개 문자 자체 | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| base64로 먼저 디코딩 | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
비밀 키 문자열도 알고리즘도 페이로드도 같은데, 나온 서명 두 개는 공통점이 하나도 없습니다. 둘 중 “틀린” 값을 쥔 쪽이 invalid signature를 보고하고, 설정 파일을 아무리 diff 해 봐야 아무것도 드러나지 않습니다. 설정 파일은 실제로 일치하기 때문입니다.
직접 재현해 보고 싶다면, UTF-8 해석에 해당하는 전체 토큰은 다음과 같습니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
위 비밀 키와 함께 JWT 디코더에 붙여 넣으면 검증에 성공합니다. 비밀 키를 base64로 먼저 디코딩하면 실패합니다.
각 라이브러리가 문자열을 키 바이트로 바꾸는 방식
문서화된 동작만 믿으세요. 그리고 첫 열보다 마지막 열이 더 중요합니다.
| 런타임 / 라이브러리 | 문자열 → 바이트 동작 | 결정하는 주체 |
|---|---|---|
Node jsonwebtoken | 문자열의 UTF-8 바이트 | 라이브러리 |
Python PyJWT | 문자열의 UTF-8 바이트 | 라이브러리 |
Java jjwt, 레거시 String 오버로드 | 플랫폼 base64 코덱(jwtk/jjwt#204 참고) | 라이브러리 |
Go golang-jwt | []byte를 그대로 받음 | 호출부, 곧 여러분 |
| .NET | byte[]를 그대로 받음 | 호출부, 곧 여러분 |
언어가 다른 스택 사이에서 벌어지는 어려움은 역사적으로 이 Java 행에서 나왔습니다. 예전 jjwt 버전에서는 signWith(SignatureAlgorithm, String)과 그 형제 메서드들이 String을 원시 바이트로 받는 대신 base64 코덱에 통과시켰고, byte[] 오버로드는 주어진 바이트를 그대로 썼습니다. 그래서 하나의 비밀 키를 공유하는 Node 서비스와 Java 서비스가 서로 어긋났던 것입니다. 이 String API는 jjwt 0.10부터 폐기 예정(deprecated)이며, 현대적인 형태는 명시적입니다:
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
이것은 “Java가 JWT를 다루는 방식”이 아닙니다. 한 라이브러리의 레거시 오버로드일 뿐이며, byte[]를 넘기는 현재의 jjwt 코드에는 모호함이 전혀 없습니다. Node 쪽에서 나온 거울상 리포트는 auth0/node-jsonwebtoken#208로, Java에서 서명한 토큰이 Node에서 검증되지 않는다는 내용입니다. PHP의 firebase/php-jwt에도 비슷한 리포트가 있습니다(firebase/php-jwt#153 참고). 다만 그 라이브러리의 바이트 처리를 직접 확인하지는 않았으므로, 진단이 아니라 단서로 취급하세요.
Go와 .NET은 다른 부류에 속합니다. 두 라이브러리 모두 여러분을 대신해 결정하지 않습니다. []byte / byte[] 매개변수를 건네주고 한 발 물러설 뿐입니다. []byte(secret)과 Encoding.UTF8.GetBytes(secret)은 UTF-8을 내놓고, Convert.FromBase64String(secret)은 디코딩된 바이트를 내놓습니다. 버그가 생긴다면 그 버그는 여러분의 호출부에 있습니다. 이는 오히려 유리한 조건입니다. 여러분 자신의 diff에서 눈에 보이기 때문입니다.
내 JWT 비밀 키는 base64입니까, UTF-8입니까?
토큰 안에 그것을 알려 주는 플래그는 없습니다. 문자열 자체를 보고 추론해야 합니다:
A–Z a–z 0–9 + / =(또는-와_)만 사용합니까? 그렇다면 base64일 수도 있습니다. 공백 문자나!,#가 들어 있는 비밀 키는 base64일 수 없습니다.- 길이가 4의 배수이거나
=패딩으로 끝납니까? 둘 다 들어오는 길목에서 무언가가 base64로 인코딩했다는 강한 힌트입니다. - base64로 디코딩하면 말이 되는 바이트가 나옵니까? Base64 디코더에 넣어 보세요. 읽을 수 있는 ASCII이거나 정확히 32바이트의 무작위처럼 보이는 값이면 base64를 시사합니다. 깨진 글자가 나온다면 그 문자열은 애초에 인코딩된 적이 없다는 뜻입니다.
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm 같은 비밀 키는 세 검사에 모두 걸립니다. 위험한 이유가 정확히 그것입니다. 모호한데다 두 해석이 모두 그럴듯합니다. -나 _가 들어 있는 비밀 키는 한층 고약한 방식으로 모호합니다. base64url로는 유효하지만 표준 base64로는 무효이기 때문입니다.
추론으로 답에 이를 수 없다면 양쪽을 다 계산하세요. signing input을 가져다 HMAC 생성기에서 HMAC-SHA256을 두 번 돌립니다. 한 번은 비밀 키를 텍스트로, 한 번은 디코딩된 바이트로 계산한 뒤 각 결과를 토큰의 세 번째 조각과 비교하세요. 둘 중 하나가 일치할 것이고, 그것이 여러분 시스템의 어느 쪽이 맞는지 알려 줍니다.
문자는 바이트가 아닙니다
이와 짝을 이루는 함정은 요구사항이 바이트 단위인데 문자를 세는 것입니다. RFC 7518 §3.2는 HMAC-SHA의 키 하한을 문자가 아니라 비트로 규정하며, 인코딩된 텍스트는 부풀어 오릅니다:
| 표기 방식 | 엔트로피 | 등가 바이트 | HS256 기준(≥256 bit 필요) |
|---|---|---|---|
| hex 문자 32개 | 128 bit | 16 바이트 | ❌ 하한 미달 |
| base64 문자 32개 | 192 bit | 24 바이트 | ❌ 하한 미달 |
| 무작위 32바이트 | 256 bit | 32 바이트 | ✅ 충족(hex로 쓰면 64자, 패딩 포함 base64로는 44자) |
“32자 비밀 키”는 알파벳에 따라 128비트일 수도, 256비트일 수도 있습니다. 위의 바이트 해석 문제와는 별개의 사안이지만 걸리는 팀은 겹칩니다. 문자로 재는 팀은 대개 바이트를 한 번도 들여다본 적 없는 팀이기 때문입니다. 길이와 인코딩을 어떻게 고르고 키를 언제 교체할지는 JWT 비밀 키 생성기의 참고 노트에 정리돼 있으니 그쪽을 보세요.
4. 비밀 키 자체가 오염된 경우
두 서비스가 바이트 해석에는 합의했습니다. 그런데도 서명이 계속 실패합니다. 이제 각 쪽이 읽어 들인 비밀 키가 정말로 여러분이 적었다고 생각하는 그 비밀 키인지 확인할 차례입니다. 환경 변수를 실어 나르는 경로 곳곳에서 바이트 하나가 끼어들기 때문입니다.
.env의 끝 줄바꿈. JWT_SECRET=abc 뒤에 줄바꿈이 오면 어떤 리더는 이를 abc\n으로 읽어 들입니다. 바이트 하나가 늘었을 뿐인데 HMAC은 전혀 무관한 출력을 냅니다. 부분적으로라도 비슷하다는 신호는 없습니다.
따옴표가 데이터로 읽히는 경우. JWT_SECRET="abc"는 어떤 로더에는 abc를, 어떤 로더에는 "abc"를 뜻합니다. 파일을 셸이 source 하는 경우와 라이브러리가 파싱하는 경우에 특히 갈립니다. Docker Compose의 env_file과 .env 파서가 같은 파일을 두고 서로 다르게 볼 수 있습니다.
복사해 붙여 넣다가 딸려 온 보이지 않는 문자. Slack이나 위키, PDF에서 비밀 키를 복사하면 폭 없는 공백(U+200B, 바이트 e2 80 8b)이나 줄바꿈 없는 공백(U+00A0, 바이트 c2 a0)이 함께 끌려올 수 있습니다. 둘 다 어떤 편집기에서도 보이지 않고, 둘 다 HMAC 결과를 바꿔 놓습니다.
CI와 컨테이너에서의 변형. 셸 보간을 거친 비밀 키는 $가 확장되거나 백슬래시가 먹힙니다. 어떤 CI 시스템은 값을 잘라 다듬고 어떤 시스템은 그러지 않습니다. 쿠버네티스 시크릿은 매니페스트에서는 base64이고 컨테이너 안에서는 원시 값이라, 그 자체로 이중 디코딩 함정입니다.
해법은 비밀 키를 눈으로 보는 대신 재는 것입니다. 양쪽에서 값이 아니라 길이와 지문을 출력하세요:
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
두 명령을 서명 측과 검증 측에서 각각 실행하고 두 출력을 비교하세요. 길이와 지문이 모두 일치하면 비밀 키는 문제가 아니므로 3절로 돌아가세요. 예상보다 길이가 1 크다면 끝 줄바꿈입니다. 2 크다면 따옴표입니다.
길이가 어긋나서 안에 정확히 무엇이 들었는지 보고 싶다면, 개발용 비밀 키를 대상으로 로컬 셸에서 hex 덤프를 뜨세요:
printf '%s' "$JWT_SECRET" | xxd
끝에 붙은 0a는 줄바꿈입니다. 앞뒤의 22는 따옴표 한 쌍입니다. 중간의 c2 a0나 e2 80 8b는 보이지 않는 문자 사례입니다. 터미널 출력이 어딘가로 실려 나가는 기기에서 운영 비밀 키를 대상으로 이 명령을 실행하지 마십시오.
실행 중인 Node나 Python 프로세스 안에서의 동등한 검사:
const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))
Python에서 len(s)가 len(s.encode("utf-8"))보다 작게 나온다면, ASCII여야 할 비밀 키에 비-ASCII 문자가 들어 있다는 뜻입니다.
5. 알고리즘과 키 유형이 맞지 않는 경우
alg 헤더와 여러분이 넘기는 키는 같은 계열에 속해야 합니다. HS256은 공유 비밀 키, 곧 바이트 문자열을 원합니다. RS256과 ES256은 비대칭 키, 곧 PEM이나 JWK를 원합니다. 이 배선을 엇갈리게 하면 라이브러리가 얼마나 관대한지에 따라 명확한 타입 오류가 나기도 하고, 밋밋한 invalid signature 하나로 끝나기도 합니다.
흔한 형태는 이렇습니다:
- 헤더는
HS256이라고 하는데 검증 측이 라이브러리에 PEM 공개 키를 건넵니다. 어떤 라이브러리는 그 PEM 텍스트로 HMAC을 계산하고 서명 불일치를 보고합니다. - 헤더는
RS256이라고 하는데 검증 측이 HMAC 비밀 키 문자열을 건넵니다. - 검증 측이 알고리즘 목록을 아예 넘기지 않고 라이브러리가
alg에서 추론하게 둡니다. 그러면 서명 측의 설정이 흔들릴 때 검증 측의 동작이 조용히 따라 바뀝니다.
마지막 항목이 바로 설정 버그가 보안 버그로 바뀌는 지점입니다. 그러니 모든 검증 호출에서 알고리즘을 명시적으로 고정하세요:
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
고정은 모호한 서명 오류를 정확한 오류로 바꿔 주기도 합니다. alg: RS256인 토큰이 도착했는데 허용 목록은 HS256이라면, 두 값을 모두 명시한 알고리즘 오류를 받게 됩니다.
이 절이 설명하는 것은 어디까지나 잘못된 설정입니다. 공격자 없이 여러분 자신의 두 구성 요소가 서로 어긋난 상황입니다. 이와 모양이 같은 별개의 실패가 있는데, 공격자가 alg를 RS256에서 HS256으로 고쳐 쓰고 여러분의 공개 키를 HMAC 비밀 키로 삼아 서명하는 경우입니다. 그것은 알고리즘 혼동이고, 버그가 아니라 공격이며, 나머지 위협 모델과 함께 JWT 보안 모범 사례에서 다룹니다. 방어책이 공교롭게도 똑같은 명시적 허용 목록인데, 버그만 쫓고 있을 때에도 이를 적용해야 할 좋은 근거가 됩니다.
6. 전송 중에 토큰이 바뀐 경우
키를 탓하기 전에, 검증 측이 서명 측이 내놓은 것과 같은 문자열을 받았는지 확인하세요. JWT도 결국 문자열이라, 문자열에 흔히 생기는 사고를 그대로 겪습니다.
Bearer 접두사. Authorization: Bearer eyJhbGci...는 헤더 값이지 토큰이 아닙니다. 엉뚱한 기준으로 자르거나, 한 번만 자르고 잘못된 쪽을 남기면 Bearer eyJhbGci...나 빈 문자열을 검증하게 됩니다. 의도적으로 벗겨 내세요:
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
공백 문자와 줄바꿈. 터미널에서 복사한 토큰은 줄이 접힙니다. YAML에 저장된 토큰도 접힙니다. 세 번째 조각 안에 \n 하나만 끼어들어도 파싱 오류가 아니라 서명 불일치가 납니다. base64url 디코더는 공백 문자를 건너뛰는 경우가 많지만 문자열 비교는 그러지 않기 때문입니다.
URL 인코딩. 쿼리 매개변수로 이동한 토큰은 .이 %2E로 바뀌어 돌아오거나, 지나치게 부지런한 인코더가 -와 _를 변환해 놓기도 합니다. 정확히 한 번만 디코딩하세요.
잘림. 쿠키는 개당 대략 4 KB에서 상한에 걸리는데, 클레임 몇 개가 붙은 RS256 토큰은 이 값을 예사로 넘습니다. 잘린 토큰은 보통 base64 디코딩에서 실패하지만, 4문자 경계에서 잘리면 겉보기에는 멀쩡하고 서명만 틀린 토큰이 됩니다.
두 개의 명령이면 결론이 납니다. 형식이 올바른 JWT에는 점이 정확히 두 개 있습니다:
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
그리고 모든 문자가 base64url 알파벳에 속해야 하므로, 다음 명령은 아무것도 출력하지 않아야 합니다:
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
두 번째 명령이 무언가를 출력했다면 그것이 곧 문제의 이름입니다. 3d는 있어서는 안 될 = 패딩이고, 2b나 2f는 base64url이 -와 _를 기대하는 자리에 들어온 표준 base64의 +와 /이며, 20은 끼어든 공백 문자입니다.
7. RS256과 ES256에서만 나타나는 실패
비대칭 알고리즘은 비밀 키 문제를 키 관리 문제로 바꿔 놓습니다. 실패 양상도 대칭 알고리즘과 딴판입니다.
PKCS#1 대 PKCS#8. 같은 RSA 키를 담는 두 가지 컨테이너 형식이며, 헤더 줄의 단어 하나로 눈에 띄게 구분됩니다:
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
라이브러리마다 받아들이는 형식이 다릅니다. 형식을 대놓고 거부하면 명확한 오류가 나오지만, 어중간하게 파싱하면 영원히 검증되지 않는 서명이 나올 수 있습니다. 씨름하지 말고 변환하세요:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
키가 서로 뒤바뀐 경우. 공개 키로 서명했거나 개인 키로 검증한 것입니다. 원리상 뻔하지만, 두 파일이 네 글자 차이 나는 이름으로 같은 디렉터리에 놓여 있으면 저지르기 쉽습니다. 어느 쪽이 어느 것인지 확인하세요:
openssl rsa -in key.pem -noout -text | head -1
개인 키는 모듈러스 크기를 개인 키로서 출력합니다. 공개 키는 -pubin을 붙이지 않으면 오류를 냅니다.
JWKS와 kid 어긋남. JWKS 엔드포인트를 쓰면 검증 측은 토큰의 kid를 키 집합과 대조해 키를 고릅니다. 어긋나는 지점은 대체로 정해져 있습니다. 서명 측이 키를 교체했는데 검증 측이 캐시한 JWKS가 낡았거나, 토큰에 kid가 없어서 검증 측이 집합의 첫 키를 고르거나, 두 환경이 겹치는 kid 값을 게시하는 경우입니다. 이것이 의심되면 JWKS를 새로 받아 와서 토큰 헤더의 정확한 kid가 그 안에 있는지 확인하세요.
ES256 서명 인코딩. ECDSA 서명은 r과 s라는 정수 한 쌍이고, 이를 직렬화하는 방식이 둘 있습니다. 범용 암호 스택은 흔히 가변 길이 ASN.1 구조인 DER를 내놓습니다. RFC 7518 §3.4는 그 대신 JOSE 형태를 요구합니다. r과 s를 각각 고정 길이로 패딩해 이어 붙인 것이며, P-256이면 64바이트입니다. JWT에 DER 서명을 집어넣으면 단지 틀린 정도가 아니라 길이 자체가 다릅니다. 그러므로 세 번째 조각이 정확히 64바이트로 디코딩되지 않는 ES256 토큰은 그 변환을 건너뛴 무언가가 내놓은 것입니다.
문제가 키에 있는지 파이프라인에 있는지 가르려면, JWT 인코더에서 같은 페이로드에 독립적으로 서명해 여러분의 서비스가 내놓은 결과와 비교하세요. 서명이 같다면 전송이나 클레임 처리를 가리키는 것이고, 다르다면 키를 가리키는 것입니다.
8. 서명 실패처럼 보이지만 아닌 오류들
이 가운데 일부는 라이브러리 자신이 이름을 잘못 붙인 것이 맞고, 그래서 엉뚱한 버그 리포트로 흘러 들어갑니다.
| 증상 | 실제 정체 | 살펴볼 곳 |
|---|---|---|
PyJWT ExpiredSignatureError | exp가 과거입니다. 이름은 서명이라 말하지만 원인은 클레임입니다. | 호스트 간 클럭 스큐, 또는 너무 짧은 TTL |
PyJWT ImmatureSignatureError | nbf가 미래입니다 | 서명 측 시계가 검증 측보다 앞서 있음 |
Node TokenExpiredError | exp가 과거입니다 | 위와 동일 |
| 상세 정보 없는 일반 401 | 프레임워크가 모든 검증 실패를 하나의 응답으로 뭉갠 것 | 라이브러리 수준 오류 로깅을 켜세요 |
| 몇 분 동안은 동작하다가 실패 | 서명이 아니라 토큰 만료 | 양쪽 호스트의 시계와 iat, exp를 비교 |
| 특정 대상(audience)에서만 실패 | aud 또는 iss 불일치 | 검증 측이 기대하는 audience 목록 |
PyJWT의 이름 짓기가 그중에서도 두드러진 함정입니다. ExpiredSignatureError에는 “signature”라는 단어가 들어 있지만, 이 예외는 서명이 이미 성공적으로 검증되고 한참 뒤인 클레임 검증 과정에서 발생합니다. 오류 문자열을 검색하면 곧장 서명 문제 해결 자료로 이어지고, 문제의 엉뚱한 대목에서 몇 시간이 사라집니다.
클럭 스큐가 만드는 양상이 가장 혼란스럽습니다. 코드의 무엇과도 상관관계가 없는 간헐적 실패입니다. 한쪽 호스트의 시계가 앞서 나가면 갓 발급된 토큰이 도착하자마자 nbf나 iat 검증에서 실패하고, 편차가 커질수록 실패가 나타나는 지점도 함께 떠돕니다. 먼저 두 기기에서 date -u를 비교하세요. 대부분의 라이브러리는 leeway 매개변수를 받는데, 이는 없앨 수 없는 스큐에는 올바른 해법이지만 정말로 고장 난 시계에는 잘못된 해법입니다.
일반 규칙은 이렇습니다. 실패가 시간에 좌우되거나, 호스트에 좌우되거나, audience에 좌우된다면 그것은 서명 문제가 아닙니다. 서명 실패는 결정적입니다. 같은 토큰과 같은 키는 언제까지나 같은 방식으로 실패합니다.
9. 반복 가능한 문제 해결 절차
아래 순서대로 실행하세요. 각 단계는 버그를 찾아내거나 한 갈래를 지워 없애며, 일찍 멈추는 것이 이 절차의 요점입니다.
- 헤더를 디코딩하세요. 토큰을 JWT 디코더에 붙여 넣고
alg와kid를 기록합니다. 이후의 모든 것이 여기서 갈리며, 키는 전혀 필요 없습니다. - 토큰의 형태를 확인하세요. 점은 정확히 두 개, 문자는 base64url 알파벳만,
Bearer접두사 없음, 공백 문자 없음. 6절의 두 명령을 쓰세요. 전송 중 손상을 제거합니다. - 검증 호출에서 알고리즘을 고정하세요.
alg와 허용 목록이 어긋나 있다면, 이제 두루뭉술한 오류 대신 두 값을 모두 명시한 오류를 받게 됩니다. - 양쪽에서 키의 지문을 뜨세요. 4절처럼 서명 측과 검증 측에서 바이트 길이와 잘라 낸 SHA-256을 출력합니다. 값이 다르면 배관이 원인이므로 5단계까지 갈 일이 없습니다.
- 양쪽 언어가 다르다면 바이트 해석을 정리하세요. 3절의 표를 참고해 비밀 키가 텍스트인지 base64인지 명시적으로 결정하고, 기본값에 맡기는 대신 양쪽 모두 코드에서 그렇게 선언하게 하세요.
- 같은 페이로드에 독립적으로 다시 서명하세요. 옳다고 믿는 키로 JWT 인코더를 사용해 그 세 번째 조각을 여러분 토큰의 세 번째 조각과 비교합니다. 일치하면 서명 측은 멀쩡하고 검증 측이 문제입니다.
- HMAC을 손으로 교차 확인하세요. signing input을 HMAC 생성기에 넣어 두 가지 바이트 해석으로 각각 돌립니다. 토큰과 일치하는 쪽이 어느 편을 고쳐야 하는지 알려 줍니다.
일곱 단계를 모두 거치고도 도움이 필요하다면, 다음 정보를 함께 적어 주세요. 대부분의 버그 리포트가 답보 상태에 빠지는 이유는 답을 결정짓는 사실이 빠져 있기 때문입니다:
- 헤더의
alg값, 그리고kid의 존재 여부 - 서명 측과 검증 측 양쪽의 언어, 라이브러리, 정확한 버전
- 양쪽 비밀 키의 바이트 길이, 그리고 그 SHA-256의 앞 16개 hex 문자(비밀 키 자체는 절대 적지 마십시오)
- 비밀 키를 텍스트로 저장하는지 base64로 저장하는지, 그리고 각 쪽이 그것을 어떻게 변환하는지
- 전체 signing input. 앞 두 조각은 민감하지 않습니다. 토큰을 가진 사람이라면 어차피 읽을 수 있습니다
- RS256과 ES256의 경우: PEM 헤더 줄을 있는 그대로
이 정도가 갖춰지면 답할 길이 없던 “JWT 서명이 일치하지 않습니다”가 누군가 실제로 풀 수 있는 질문이 되고, 대개 답변 한 번이면 끝납니다.
FAQ
왜 같은 비밀 키가 한 언어에서는 되고 다른 언어에서는 실패합니까?
비밀 키 문자열을 키 바이트로 바꾸는 방식을 두고 라이브러리마다 생각이 다르기 때문입니다. Node jsonwebtoken과 Python PyJWT는 UTF-8을 쓰고, jjwt의 레거시 String 오버로드는 base64 코덱을 썼으며(jwtk/jjwt#204), Go와 .NET은 그 결정을 호출부에 맡깁니다. 문자는 같은데 바이트가 다르고, 그래서 HMAC도 다릅니다.
서명은 디코딩된 페이로드를 덮습니까, 인코딩된 문자열을 덮습니까?
인코딩된 문자열입니다. RFC 7515는 signing input을 리터럴 ASCII로서 base64url(header) + "." + base64url(payload)로 정의합니다. 페이로드를 역직렬화했다가 다시 직렬화하는 계층은 키 순서나 공백 문자, 숫자 형식을 바꾸고, 그 결과 다른 문자열과 다른 서명이 나옵니다.
비밀 키가 base64처럼 보이는데, 서명 전에 디코딩해야 합니까?
반대편도 그렇게 할 때만 그렇습니다. 따로 떼어 놓고 보면 정답은 없습니다. 양 끝이 합의하기만 하면 됩니다. 문자열이 base64 문자만 쓰는지, 길이가 4의 배수인지 확인한 다음, 기본값에 기대는 대신 양쪽 코드에서 그 선택을 명시하세요.
.env의 끝 줄바꿈 하나가 정말로 서명을 깨뜨릴 수 있습니까?
그렇습니다. HMAC은 바이트를 소비하는데, abc가 3바이트라면 abc\n은 4바이트입니다. 그 결과로 나오는 서명은 올바른 서명과 아무 공통점이 없습니다. 양쪽 호스트에서 printf '%s' "$JWT_SECRET" | wc -c를 출력해 보세요. 예상보다 길이가 1 크다면 거의 언제나 이것입니다.
비밀 키 문제인지 알고리즘 문제인지 어떻게 구분합니까?
먼저 헤더에서 alg를 읽으세요. HS로 시작하면 공유 비밀 키가 필요하므로 PEM은 실패합니다. RS, PS, ES로 시작하면 키 쌍이 필요하므로 비밀 키 문자열은 실패합니다. alg와 키 유형이 같은 계열로 맞춰진 뒤에도 남는 실패는 키 내용의 문제입니다.
jwt.io는 서명이 유효하다는데 왜 제 서버는 거부합니까?
온라인 도구와 여러분의 서버가 비밀 키를 다르게 해석할 수 있기 때문입니다. 한쪽은 UTF-8 텍스트로, 다른 쪽은 base64로 읽는 것입니다. 그 도구는 자기가 도출한 바이트를 기준으로 검증하지, 여러분 서버가 도출한 바이트를 기준으로 검증하지 않습니다. 그리고 운영 비밀 키는 절대 제3자 사이트에 붙여 넣지 마십시오. 개발용 키를 쓰세요.
invalid signature가 만료된 토큰 때문에 발생하는 경우도 있습니까?
아닙니다. 서명 검증은 클레임 검증보다 먼저 실행되므로 만료가 원인이 되는 일은 없습니다. 만료는 Node에서는 TokenExpiredError로, PyJWT에서는 ExpiredSignatureError로 따로 드러납니다. 후자의 이름은 오해를 부르는데, 서명은 멀쩡히 검증됐고 exp만 실패했기 때문입니다.
결론
서명 불일치가 암호학의 문제인 경우는 거의 없습니다. HMAC-SHA256도 RSA도 제 몫을 합니다. 실패하는 곳은 문자열이 바이트가 되는 경계입니다. 한쪽은 base64 코덱이고 다른 쪽은 UTF-8인 경우, 설정 로더가 남겨 둔 줄바꿈 하나, 게이트웨이가 친절하게 다시 직렬화해 준 페이로드 같은 것들 말입니다. 이 글에 나오는 원인은 전부 바이트를 두고 벌어진 이견입니다.
그러니 바이트를 명시적으로 정하고, 기본값에 기대는 일을 그만두세요. 공유 비밀 키를 원시 텍스트로 저장하는지 base64로 저장하는지 팀 문서에 적어 두고, 모든 서비스가 각자의 라이브러리가 가정한 방식을 물려받는 대신 선언된 방식대로 변환하게 하세요. 여러 언어에 걸친 시스템이라면 비밀 키를 hex나 base64로 저장하고 모든 호출부에서 명시적으로 디코딩하세요. 서비스당 한 줄이면 모호함이 사라집니다. 그런 다음 4절의 바이트 길이 지문을 헬스 체크에 추가해, 다음번 불일치가 운영 환경의 401이 아니라 기동 시 경고로 드러나게 하세요.