/보안/JWT Invalid Signature 401 Errors: Diagnosing 6 Causes (Spring/Node/Python)
SecurityJWTinvalid signature

JWT Invalid Signature 401 Errors: Diagnosing 6 Causes (Spring/Node/Python)

When a 401 hits with JWT "invalid signature" or "signature does not match," diagnose six causes—secret mismatch, HS256/RS256 confusion, encoding traps, and more—then fix it immediately with Spring, Node, and Python code plus copy-paste comm

JWT Invalid Signature 401 Errors: Diagnosing 6 Causes (Spring/Node/Python)

JWT Invalid Signature 401 Errors: Diagnosing 6 Causes (Spring/Node/Python)

"It works fine locally, then I ship to prod and 401s start flooding in." If you handle JWTs as a backend developer, you've hit this nightmare at least once. The logs show messages like SignatureException, JWT signature does not match locally computed signature, or signature verification failed. Bottom line: these messages almost all mean the same thing — "I re-signed the token with the key I have, and it doesn't match the signature embedded in the token." The libraries (jjwt/jsonwebtoken/PyJWT) just word it differently.

What matters is what 'invalid signature' does not mean. It is a completely different problem from an expired token (exp) or a malformed token. Mix those up and you'll spend days rotating a perfectly good secret. This post is a checklist that narrows a vague "signature error" into six branches so you can pinpoint the cause in five minutes.

6-branch diagnostic checklist ① Secret/public-key mismatch → ② Algorithm mismatch (HS256↔RS256) → ③ Token tampering or Base64url corruption → ④ Secret encoding difference (plaintext vs base64) → ⑤ Issuer↔verifier server sync → ⑥ Library verification code error

First-pass diagnosis: signature failure vs expiry vs tampering (30 seconds)

To avoid digging in the wrong place, separate these three first. No amount of secret-changing will ever fix an expiry (exp) problem.

Symptom message examplesActual causeHow to checkNever do this
invalid signature, JWT signature does not match, SignatureExceptionVerification key/algorithm/encoding doesn't match issuancePaste the secret into jwt.io for ✅/❌, check header algDon't touch exp or clock sync
token expired, ExpiredJwtException, jwt expiredPayload exp is before nowDecode payload exp and compare with current epochDon't rotate the secret (won't help)
malformed token, Invalid token, DecodeError, jwt malformedDot (.) count ≠ 2, Base64url broken, Bearer prefix mixed inCount . in the token, check leading/trailing whitespaceDon't suspect the signing key

The fastest first-pass branch is opening the header.

Bash
# 헤더(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/null

If alg is HS256 but the verification code uses RS256 → branch ②. If exp is in the past, it's an expiry problem, not a signature problem.

Cause branches ①–⑤ — keys, algorithms, encoding, sync

① Secret/public-key mismatch — the most common. A typo in the .env secret, a key rotation that never reached the verifying server, or a JWKS cache still holding an old public key. For RS256, the verifier must have the exact public key that corresponds to the issuer's private key.

② Algorithm mismatch — if issuance is HS256 (symmetric) but verification is set to RS256 (asymmetric), the signature will never match. More dangerous still are alg: none downgrade attacks and algorithm confusion. Attackers change the header to HS256 and treat the public key as an HMAC secret to forge a signature — that pattern was at the core of several library CVEs in 2023–2024. That's why explicitly whitelisting allowed algorithms at verification time is not optional; it's required.

③ Token tampering or Base64url corruption — passing the token as a URL parameter breaks + / =, or the client sends the Bearer prefix, quotes, or newlines still attached. Always strip Bearer and trim() before verifying.

④ Secret encoding difference — a trap people hit constantly. Even with the same string, if one side builds the key from plaintext bytes and the other base64-decodes it first, the byte arrays differ and the signature won't match.

JAVA
// 발급 서버 (jjwt): 평문 바이트 → 키
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));

// 검증 서버 (jjwt): base64 디코딩 → 키  ❗ 위와 다른 바이트!
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));

On top of that, Secret Manager or .env often appends a newline (\n) or trailing space to the secret. You can't see it, which makes it nastier. Check at the byte level:

Bash
# 시크릿 끝 개행/공백 확인 — 끝에 0a(=\n)나 20(=space)이 있으면 범인
printf '%s' "$JWT_SECRET" | xxd | tail

⑤ Issuer↔verifier server sync — in a microservices architecture, the API gateway and each service get different secrets injected, or container environments (dev/staging/prod) get different values. Classic "works locally, fails in prod." Unify issuance and verification secrets from a single source (AWS Secrets Manager or Vault) and it often disappears in one shot.

A note from production

Most production 'invalid signature' incidents are ④ (trailing newline on the secret) or ⑤ (env-specific secret injection mismatch). Before you suspect the code, dump the bytes with xxd — that's the fastest path. The code is the same locally and in prod; what's different is almost always the injected value.

Correct verification code by library — branch ⑥

The key is to always pin the allowed algorithms.

jjwt (Spring Security, 0.12.x)

JAVA
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)

JavaScript
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)

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 vs RS256: where they differ

AspectHS256 (symmetric)RS256 (asymmetric)
KeyOne shared secret for sign and verifySign with private key, verify with public key
'does not match' scenariosIssuer/verifier secret string or encoding mismatchVerifier public key is not the pair of the issuer private key; JWKS cache stale
Key rotationHard to swap the secret simultaneouslyZero-downtime rotation via JWKS endpoint + header kid
Microservices fitBurden of sharing the secretDistribute public keys only (OIDC standard)

As OAuth 2.1/OIDC has become the default, production environments are moving toward JWKS + kid-based key rotation. Look up the matching public key via the token header's kid, and keep JWKS cache TTL short when rotating keys — that's standard practice.

One-line fix summary by cause

BranchSymptomOne-line fix
① Key mismatch❌ on jwt.ioUnify issuer/verifier keys from the same source
② AlgorithmHeader alg ≠ verify algExplicitly whitelist algorithms
③ Token corruption. ≠ 2, Bearer mixed inStrip Bearer + trim()
④ EncodingSame string, still failsCheck newlines with xxd, unify plaintext vs base64
⑤ SyncWorks only locallySingle-source via Secret Manager
⑥ CodeLibrary misuseApply the verification code above as-is

Recurrence-prevention checklist: pin allowed algorithms / trim and length-check secrets / rotate keys with kid / manage issuer and verifier secrets in a single Secret Manager.

References: official docs

The primary source for the behavior, configuration, and errors covered here is the following official document. Check version-specific options and exact behavior there.

FAQ

Q. I put in the exact same secret — why do I still get 'invalid signature'? A. Almost always an invisible difference. Run printf '%s' "$JWT_SECRET" | xxd | tail to see if a newline (0a) or space (20) is stuck on the end, and check that one side isn't building the key from plaintext bytes while the other base64-decodes it.

Q. How do I quickly tell 'invalid signature' from 'token expired'? A. Decode the payload (echo $TOKEN | cut -d. -f2 | base64 -d) and compare exp with the current epoch. If exp is in the past, it's an expiry problem — rotating the secret will never fix it.

Q. Do I really have to specify algorithms in the verification code? A. Yes, it's required. If you omit it, you're exposed to alg: none downgrade and algorithm-confusion attacks that misuse an RS256 public key as an HS256 secret. That was the root cause of multiple CVEs in 2023–2024.

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

Comments

Be the first to comment.