JWT invalid signature 401 에러 6가지 원인 진단 (Spring/Node/Python)
"로컬에선 잘 되는데 운영에 올리니 갑자기 401이 쏟아진다." JWT를 다루는 백엔드 개발자라면 한 번쯤 겪는 악몽입니다. 로그를 보면 SignatureException, JWT signature does not match locally computed signature, signature verification failed 같은 메시지가 보이죠. 결론부터 말하면, 이 메시지들은 거의 다 같은 말입니다 — "내가 가진 키로 토큰을 다시 서명해 봤더니 토큰에 박힌 서명과 안 맞는다." 라이브러리(jjwt/jsonwebtoken/PyJWT)마다 문구만 다를 뿐이죠.
중요한 건 'invalid signature'가 의미하지 않는 것입니다. 토큰이 만료(exp)됐거나 형식이 깨진(malformed) 것과는 전혀 다른 문제입니다. 이걸 혼동하면 멀쩡한 시크릿을 바꾸며 며칠을 날립니다. 이 글은 막연한 '서명 오류'를 6개 분기로 좁혀 5분 안에 원인을 특정하는 체크리스트입니다.
6-분기 진단 체크리스트 ① 시크릿/공개키 불일치 → ② 알고리즘 불일치(HS256↔RS256) → ③ 토큰 변조·Base64url 손상 → ④ 시크릿 인코딩 차이(평문 vs base64) → ⑤ 발급↔검증 서버 동기화 → ⑥ 라이브러리 검증 코드 오류
1차 진단: 서명 실패 vs 만료 vs 변조 (30초)
엉뚱한 곳을 파지 않으려면 먼저 세 가지를 구분하세요. 시크릿을 아무리 바꿔도 만료(exp) 문제는 절대 안 고쳐집니다.
| 증상 메시지 예시 | 실제 원인 | 확인 방법 | 절대 하지 말 것 |
|---|---|---|---|
invalid signature, JWT signature does not match, SignatureException | 검증 키/알고리즘/인코딩이 발급과 불일치 | jwt.io에 시크릿 넣어 ✅/❌, 헤더 alg 확인 | exp·시계 동기화 만지기 |
token expired, ExpiredJwtException, jwt expired | 페이로드 exp가 현재 시각 이전 | 페이로드 exp 디코딩해 현재 epoch와 비교 | 시크릿 교체 (효과 없음) |
malformed token, Invalid token, DecodeError, jwt malformed | 점(.) 개수 ≠ 2개, Base64url 깨짐, Bearer 접두어 혼입 | 토큰의 . 개수 세기, 앞뒤 공백 확인 | 서명 키 의심 |
가장 빠른 1차 분기는 헤더부터 까보는 것입니다.
# 헤더(alg) 확인 — base64url은 -d로 디코딩 (padding 경고는 무시 가능)
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null
# 결과 예: {"alg":"HS256","typ":"JWT"} ← 검증 코드의 알고리즘과 일치하는가?
# 페이로드(exp) 확인 — 만료부터 배제
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/nullalg가 HS256인데 검증 코드는 RS256을 쓰고 있다면 → 분기 ②. exp가 과거라면 서명이 아니라 만료 문제입니다.
원인 분기 ①~⑤ — 키·알고리즘·인코딩·동기화
① 시크릿/공개키 불일치 — 가장 흔합니다. .env의 시크릿 오타, 키 회전(rotation) 후 검증 서버에 새 키 미반영, JWKS 캐시가 옛 공개키를 물고 있는 경우죠. RS256이라면 발급 측 개인키에 대응하는 정확한 공개키가 검증 측에 있어야 합니다.
② 알고리즘 불일치 — 발급은 HS256(대칭키)인데 검증은 RS256(비대칭키)로 설정돼 있으면 서명이 절대 맞지 않습니다. 더 위험한 건 alg: none 다운그레이드 공격과 알고리즘 혼동입니다. 공격자가 헤더를 HS256으로 바꾸고, 공개키를 HMAC 시크릿처럼 사용해 서명을 위조하는 패턴이 2023~2024년 여러 라이브러리 CVE의 핵심이었죠. 그래서 검증 시 허용 알고리즘 화이트리스트 명시는 선택이 아니라 필수입니다.
③ 토큰 변조·Base64url 손상 — 토큰을 URL 파라미터로 전달하다 + / =가 깨지거나, 클라이언트가 Bearer 접두어·따옴표·줄바꿈을 그대로 붙여 보내는 경우입니다. 검증 전 반드시 Bearer 제거 후 trim().
④ 시크릿 인코딩 차이 — 정말 많이 당하는 함정입니다. 같은 문자열인데도 한쪽은 평문 바이트로, 다른 쪽은 base64 디코딩해서 키를 만들면 바이트 배열이 달라져 서명이 어긋납니다.
// 발급 서버 (jjwt): 평문 바이트 → 키
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
// 검증 서버 (jjwt): base64 디코딩 → 키 ❗ 위와 다른 바이트!
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));여기에 더해 Secret Manager나 .env에서 시크릿 끝에 **개행(\n)**이나 공백이 딸려 들어오는 경우. 눈에 안 보여서 더 악랄합니다. 바이트 단위로 확인하세요:
# 시크릿 끝 개행/공백 확인 — 끝에 0a(=\n)나 20(=space)이 있으면 범인
printf '%s' "$JWT_SECRET" | xxd | tail⑤ 발급↔검증 서버 동기화 — MSA에서 API 게이트웨이와 각 서비스가 다른 시크릿을 주입받거나, 컨테이너 환경(dev/staging/prod)별로 다른 값이 들어가는 경우입니다. "로컬은 되는데 운영은 안 되는" 전형적 시나리오죠. 발급/검증 시크릿을 단일 출처(AWS Secrets Manager·Vault)에서 가져오게 통일하면 한 방에 해결됩니다.
실무 경험 한마디
제 경험상 운영에서 터지는 'invalid signature'의 70%는 ④번, 시크릿 끝 개행이거나 ⑤번, 환경별 시크릿 주입 불일치였습니다. 코드를 의심하기 전에 xxd로 바이트부터 찍어보는 게 가장 빠른 길입니다. 코드는 로컬과 운영이 똑같으니까요 — 다른 건 거의 항상 '주입되는 값'입니다.
라이브러리별 올바른 검증 코드 — 분기 ⑥
핵심은 허용 알고리즘을 반드시 고정하는 것입니다.
jjwt (Spring Security, 0.12.x)
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
Jws<Claims> jws = Jwts.parser()
.verifyWith(key) // HS256 시크릿 키
// RS256이면: .verifyWith(publicKey)
.build()
.parseSignedClaims(token); // 서명·exp 자동 검증jsonwebtoken (Node.js)
const jwt = require('jsonwebtoken');
// algorithms 미지정 시 alg 혼동 공격에 노출 — 반드시 명시!
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
// 시크릿 바이트 길이 확인 원라이너 (인코딩 함정 진단)
// node -e "console.log(Buffer.from(process.env.JWT_SECRET).length, JSON.stringify(process.env.JWT_SECRET.slice(-3)))"PyJWT (Python)
import jwt
# HS256: 공유 시크릿
payload = jwt.decode(token, key, algorithms=["HS256"])
# RS256: 공개키 PEM 전달
with open("public.pem") as f:
public_key = f.read()
payload = jwt.decode(token, public_key, algorithms=["RS256"])HS256 ↔ RS256, 어디서 다른가
| 구분 | HS256 (대칭키) | RS256 (비대칭키) |
|---|---|---|
| 키 | 공유 시크릿 1개로 서명·검증 | 개인키로 서명, 공개키로 검증 |
| 'does not match' 시나리오 | 발급/검증 시크릿 문자열·인코딩 불일치 | 검증 측 공개키가 발급 개인키와 짝이 아님, JWKS 캐시 만료 |
| 키 회전 | 시크릿 동시 교체 어려움 | JWKS 엔드포인트 + 헤더 kid로 무중단 회전 |
| MSA 적합성 | 시크릿 공유 부담 | 공개키만 배포하면 됨 (OIDC 표준) |
OAuth2.1/OIDC가 보편화되면서 운영 환경은 점점 JWKS + kid 기반 키 회전으로 가고 있습니다. 토큰 헤더의 kid로 맞는 공개키를 찾아 검증하고, 키 회전 시 JWKS 캐시 TTL을 짧게 두는 게 표준 관행입니다.
원인별 1줄 처방 요약표
| 분기 | 증상 | 1줄 처방 |
|---|---|---|
| ① 키 불일치 | jwt.io에서 ❌ | 발급/검증 키를 동일 출처로 통일 |
| ② 알고리즘 | 헤더 alg ≠ 검증 alg | algorithms 화이트리스트 명시 |
| ③ 토큰 손상 | .≠2, Bearer 혼입 | Bearer 제거 + trim() |
| ④ 인코딩 | 같은 문자열인데 실패 | xxd로 개행 확인, 평문/base64 통일 |
| ⑤ 동기화 | 로컬만 됨 | Secret Manager 단일 출처화 |
| ⑥ 코드 | 라이브러리 오용 | 위 검증 코드 그대로 적용 |
재발 방지 체크리스트: 허용 알고리즘 고정 / 시크릿 trim·길이 검증 / 키 회전은 kid 활용 / 발급·검증 시크릿을 단일 Secret Manager로 관리.
자주 묻는 질문 (FAQ)
Q. 시크릿을 분명히 똑같이 넣었는데 왜 'invalid signature'가 나나요?
A. 십중팔구 보이지 않는 차이입니다. printf '%s' "$JWT_SECRET" | xxd | tail로 끝에 개행(0a)이나 공백(20)이 붙었는지 확인하고, 한쪽은 평문 바이트·다른 쪽은 base64 디코딩으로 키를 만들지 않는지 점검하세요.
Q. 'invalid signature'와 'token expired'는 어떻게 빠르게 구분하나요?
A. 페이로드를 디코딩(echo $TOKEN | cut -d. -f2 | base64 -d)해 exp 값을 현재 epoch와 비교하세요. exp가 과거면 만료 문제이고, 시크릿 교체로는 절대 안 고쳐집니다.
Q. 검증 코드에 algorithms를 꼭 명시해야 하나요?
A. 네, 필수입니다. 미지정 시 alg: none 다운그레이드와 RS256 공개키를 HS256 시크릿으로 오용하는 알고리즘 혼동 공격에 노출됩니다. 2023~2024년 다수 CVE의 원인이었습니다.
이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.
댓글
불러오는 중...