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 examples | Actual cause | How to check | Never do this |
|---|---|---|---|
invalid signature, JWT signature does not match, SignatureException | Verification key/algorithm/encoding doesn't match issuance | Paste the secret into jwt.io for ✅/❌, check header alg | Don't touch exp or clock sync |
token expired, ExpiredJwtException, jwt expired | Payload exp is before now | Decode payload exp and compare with current epoch | Don't rotate the secret (won't help) |
malformed token, Invalid token, DecodeError, jwt malformed | Dot (.) count ≠ 2, Base64url broken, Bearer prefix mixed in | Count . in the token, check leading/trailing whitespace | Don't suspect the signing key |
The fastest first-pass branch is opening the header.
# 헤더(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/nullIf 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.
// 발급 서버 (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:
# 시크릿 끝 개행/공백 확인 — 끝에 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)
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 vs RS256: where they differ
| Aspect | HS256 (symmetric) | RS256 (asymmetric) |
|---|---|---|
| Key | One shared secret for sign and verify | Sign with private key, verify with public key |
| 'does not match' scenarios | Issuer/verifier secret string or encoding mismatch | Verifier public key is not the pair of the issuer private key; JWKS cache stale |
| Key rotation | Hard to swap the secret simultaneously | Zero-downtime rotation via JWKS endpoint + header kid |
| Microservices fit | Burden of sharing the secret | Distribute 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
| Branch | Symptom | One-line fix |
|---|---|---|
| ① Key mismatch | ❌ on jwt.io | Unify issuer/verifier keys from the same source |
| ② Algorithm | Header alg ≠ verify alg | Explicitly whitelist algorithms |
| ③ Token corruption | . ≠ 2, Bearer mixed in | Strip Bearer + trim() |
| ④ Encoding | Same string, still fails | Check newlines with xxd, unify plaintext vs base64 |
| ⑤ Sync | Works only locally | Single-source via Secret Manager |
| ⑥ Code | Library misuse | Apply 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.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.