- Published on
JWT invalid signature 서명검증 실패 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT를 검증하다가 invalid signature(서명검증 실패)를 만나면, 많은 개발자가 “토큰이 위조됐나?”부터 의심합니다. 물론 위조 가능성도 있지만, 실무에서는 정상 발급 토큰인데도 검증 환경/설정 차이로 실패하는 경우가 훨씬 흔합니다. 특히 마이크로서비스, 멀티 리전, 로드밸런서/프록시, 여러 언어 런타임이 섞인 환경에서는 “같은 JWT인데 어떤 서비스에서는 되고 어떤 서비스에서는 안 되는” 현상이 쉽게 발생합니다.
이 글에서는 invalid signature를 재현 가능한 원인 7가지로 나눠 설명하고, 각 원인마다 확인 포인트와 코드/명령 예시를 제공합니다. (참고로 인증/인가 전반의 401 이슈와 함께 보려면 Kubernetes 401 Unauthorized 원인별 해결 가이드도 같이 보면 진단 속도가 빨라집니다.)
JWT 서명검증이 실패하는 메커니즘 간단 정리
JWT는 header.payload.signature 3파트로 구성됩니다.
header: 알고리즘(alg), 키 식별자(kid) 등payload: 클레임(sub,iss,aud,exp등)signature:base64url(header) + "." + base64url(payload)를 키와 알고리즘으로 서명한 결과
검증은 아래가 일치하는지 확인하는 과정입니다.
- 검증자가 토큰의
header/payload를 동일한 방식으로 인코딩해 메시지를 만들고 - 검증자가 가진 키로 같은 알고리즘을 적용해 서명을 계산한 뒤
- 토큰에 들어있는
signature와 비교
따라서 invalid signature는 대부분 (a) 키가 다르거나 (b) 알고리즘이 다르거나 (c) 메시지 바이트열이 달라졌거나 중 하나입니다.
원인 1) 서명 키가 다름(환경 변수/시크릿/파일 불일치)
가장 흔한 원인입니다. 발급 서비스는 JWT_SECRET=prod-aaa로 서명했는데, 검증 서비스는 JWT_SECRET=prod-aaaa처럼 한 글자만 달라도 즉시 실패합니다.
체크리스트
- 발급자/검증자 모두 같은 키를 보고 있는가?
- K8s Secret/ConfigMap의 네임스페이스가 다른 건 아닌가?
- CI/CD에서 시크릿이 덮어씌워졌거나, 롤백으로 키가 바뀌지 않았나?
Node.js 예시 (jsonwebtoken)
import jwt from "jsonwebtoken";
const token = process.env.TOKEN;
const secret = process.env.JWT_SECRET;
try {
const decoded = jwt.verify(token, secret);
console.log(decoded);
} catch (e) {
console.error(e.name, e.message); // JsonWebTokenError invalid signature
}
진단 팁
- 발급 로그에 key id(kid) 또는 키 버전을 남기고, 검증 서비스에서도 같은 키 버전을 선택했는지 확인하세요.
원인 2) 알고리즘 불일치(HS256 vs RS256 등)
발급자는 RS256(비대칭키)로 서명했는데, 검증자는 HS256(대칭키)로 검증하려 하면 실패합니다. 반대로도 마찬가지입니다.
체크리스트
- 토큰 헤더의
alg가 무엇인지 확인 - 검증 라이브러리에서 허용 알고리즘을 제한하고 있는지 확인
토큰 헤더 확인 (base64url 디코딩)
# header 파트만 떼서 base64url 디코딩
python - <<'PY'
import base64, json, os
h = os.environ['JWT'].split('.')[0]
pad = '=' * (-len(h) % 4)
print(json.loads(base64.urlsafe_b64decode(h + pad)))
PY
Java (jjwt)에서 알고리즘/키 종류 주의
HS256:SecretKey(공유 비밀키)RS256:PublicKey로 검증
Jwts.parserBuilder()
.setSigningKey(publicKey) // RS256이면 publicKey
.build()
.parseClaimsJws(jwt);
원인 3) HS256에서 “문자열 시크릿” 인코딩/바이트 불일치
HS256은 결국 바이트 배열로 HMAC을 계산합니다. 같은 문자열이라도 바이트 변환이 달라지면(UTF-8 vs 다른 인코딩, 공백/개행 포함 등) 서명이 달라집니다.
흔한 케이스
.env에JWT_SECRET="abc"처럼 따옴표가 포함됨- K8s Secret에 개행이 들어감 (
echo로 만들 때-n안 써서\n포함) - Base64로 저장된 값을 그대로 쓰거나, 반대로 디코딩해야 할 값을 디코딩하지 않음
K8s Secret 개행 이슈 재현/예방
# 잘못: 개행 포함
echo "mysecret" | kubectl create secret generic jwt --from-file=secret=/dev/stdin
# 권장: 개행 제거
printf %s "mysecret" | kubectl create secret generic jwt --from-file=secret=/dev/stdin
Node.js에서 명시적으로 바이트 처리
const secret = Buffer.from(process.env.JWT_SECRET, "utf8");
jwt.verify(token, secret, { algorithms: ["HS256"] });
원인 4) RS256/ES256에서 공개키/개인키 포맷 또는 PEM 파싱 문제
비대칭키는 포맷 문제가 잦습니다.
흔한 케이스
- PEM 헤더/푸터 누락 (
-----BEGIN PUBLIC KEY-----) - 줄바꿈이
\n문자열로 들어와 실제 개행이 아님 BEGIN RSA PUBLIC KEYvsBEGIN PUBLIC KEY(PKCS#1 vs PKCS#8)- JWK를 PEM으로 변환하는 과정에서 키가 깨짐
줄바꿈 복원 예시 (환경변수에 PEM 저장했을 때)
const pem = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, "\n");
jwt.verify(token, pem, { algorithms: ["RS256"] });
OpenSSL로 공개키 확인
openssl pkey -pubin -in public.pem -text -noout
원인 5) kid/JWKS 키 로테이션 중 잘못된 키 선택(캐시/동기화 문제)
OAuth/OIDC 환경에서는 보통 JWKS(JSON Web Key Set)를 통해 공개키를 가져옵니다. 이때 kid에 해당하는 키를 못 찾거나, 로테이션 직후 캐시가 오래된 키를 계속 쓰면 서명 검증이 실패합니다.
체크리스트
- 토큰 헤더의
kid와 JWKS의kid가 매칭되는가? - JWKS 캐시 TTL이 과도하게 길지 않은가?
- 발급자에서 키 로테이션이 발생했는지(새
kid발급)
Node.js (jose) JWKS 예시
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://issuer.example.com/.well-known/jwks.json"));
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: "https://issuer.example.com/",
audience: "my-api",
});
console.log(protectedHeader.kid, payload.sub);
운영 팁
- 검증 실패 시 JWKS 재조회(캐시 무효화) 후 재시도 전략을 넣으면 로테이션 순간 장애를 줄일 수 있습니다.
원인 6) 토큰이 전송/저장 과정에서 변형됨(공백, 잘림, 인코딩)
서명은 토큰 문자열이 단 1바이트만 달라도 실패합니다. 전송 계층에서 토큰이 변형되는 경우가 의외로 많습니다.
흔한 케이스
Authorization: Bearer <token>에서Bearer파싱 실수(앞뒤 공백 포함)- 프록시/게이트웨이가 헤더 길이 제한으로 토큰을 잘라버림
- 쿠키 저장 시 URL 인코딩/디코딩이 어긋나
+/%2B같은 변형 발생 - 줄바꿈이 포함된 토큰(로그 복사/붙여넣기) 사용
Express에서 안전한 Bearer 파싱
function getBearer(req) {
const h = req.headers.authorization;
if (!h) return null;
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1].trim() : null;
}
Nginx/ALB 계층 점검 포인트
large_client_header_buffers(Nginx)- ALB/Ingress의 헤더 크기 제한
- WAF/보안장비가 특정 패턴을 변조/차단하는지
인프라 단에서 401/502/503이 섞여 보인다면 게이트웨이·프록시 관점의 장애 패턴도 같이 보세요. 예를 들어 EKS 환경에서 게이트웨이 계층 문제를 다룬 글로 EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지가 있습니다.
원인 7) “검증 옵션” 문제를 서명 문제로 오해(iss/aud/clock skew)
라이브러리에 따라 iss/aud/exp/nbf 검증 실패가 invalid signature처럼 뭉뚱그려 보이거나, 로그가 부족해 서명 실패로 오해하는 경우가 있습니다.
체크리스트
issuer(iss),audience(aud)를 강제하는데 값이 다르지 않은가?- 서버 시간이 틀려서
exp/nbf검증이 실패하지 않는가? - 라이브러리 예외 타입/메시지를 구분해서 로깅하고 있는가?
jose에서 원인 구분 로깅
import { jwtVerify, errors } from "jose";
try {
await jwtVerify(token, key, { issuer, audience });
} catch (e) {
if (e instanceof errors.JWTExpired) {
console.error("expired", e.code);
} else if (e instanceof errors.JWTClaimValidationFailed) {
console.error("claim validation failed", e.claim, e.reason);
} else {
console.error("verify failed", e.name, e.message);
}
}
시간 오차(Clock Skew) 허용
await jwtVerify(token, key, {
issuer,
audience,
clockTolerance: "5s", // 또는 숫자(초)
});
빠른 진단 순서(실무용)
장애 상황에서 시간을 줄이려면 아래 순서가 효율적입니다.
- 토큰 원문 확보: 클라이언트/게이트웨이/백엔드 로그에서 동일 토큰인지 확인(잘림/공백/인코딩 우선 제거)
- header 디코딩:
alg,kid,typ확인 - 키 선택 확인:
kid→JWKS 매칭, 로테이션 여부, 캐시 TTL 확인 - 키 포맷/개행 확인: PEM/JWK 변환,
\\n처리 - HS256이면 바이트 확인: 개행/따옴표/Base64 처리
- 검증 옵션 분리:
issuer/audience/exp/nbf를 잠시 로그로 분리해 원인 특정 - 재현 테스트: 로컬에서 동일 라이브러리로 verify 재현(키/토큰 고정)
로컬에서 “정말 서명 문제인지” 확인하는 최소 재현 코드
발급자와 검증자가 다른 런타임이면, 한쪽에서 토큰을 발급하고 다른 쪽에서 검증하는 교차 테스트가 빠릅니다.
(1) Node.js로 HS256 토큰 발급
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: "123", aud: "my-api", iss: "https://issuer.example.com" },
Buffer.from("mysecret", "utf8"),
{ algorithm: "HS256", expiresIn: "10m" }
);
console.log(token);
(2) 같은 키로 즉시 검증
import jwt from "jsonwebtoken";
const token = process.argv[2];
const secret = Buffer.from("mysecret", "utf8");
console.log(jwt.verify(token, secret, {
algorithms: ["HS256"],
audience: "my-api",
issuer: "https://issuer.example.com",
}));
여기서도 실패한다면 토큰/키 자체가 문제고, 여기서는 성공하는데 운영에서만 실패한다면 전송/캐시/키 선택/환경변수 쪽이 문제일 확률이 높습니다.
마무리
invalid signature는 “서명 알고리즘과 키로 계산한 결과가 토큰의 signature와 다르다”는 뜻이지만, 그 배경은 다양합니다. 실무에서는 특히 키 불일치(원인 1), 알고리즘 불일치(원인 2), 키 로테이션/JWKS 캐시(원인 5), **전송 중 토큰 변형(원인 6)**이 대부분을 차지합니다.
인증 실패가 K8s/EKS 환경에서 관측된다면, 애플리케이션 설정뿐 아니라 게이트웨이/Ingress/프록시 계층의 제한도 함께 보세요. 인증 오류 전반의 점검 관점은 Kubernetes 401 Unauthorized 원인별 해결 가이드에서 보완할 수 있습니다.