When I see invalid signature, JWT signature verification failed, or JWSSignatureVerificationFailed, I do not start by editing the token or disabling checks. I preserve the exact compact JWT and split the investigation into four inputs: signed bytes, algorithm policy, key material, and key selection.
If I already have trusted HMAC or RSA key material, I reproduce the cryptographic check in the JWT Signature Verifier. If verification depends on a key set, I use the JWKS Validator.
What an invalid signature error proves — and what it does not
A compact JWS signs this input:
base64url(header) + "." + base64url(payload)
The verifier recomputes or checks a signature using trusted key material. Failure means those cryptographic inputs do not line up.
It does not by itself mean:
- the token is expired;
audis wrong;- the user lacks a role;
- the JSON is malformed;
- the JWT library is broken.
Those are separate layers.
My first branch: HMAC or public-key signature?
I decode the header only as a diagnostic clue:
{ "alg": "HS256" }
or:
{ "alg": "RS256", "kid": "prod-2026-09" }
Then I branch:
HS256 / HS384 / HS512
-> secret bytes, encoding, secret rotation
RS256 / RS384 / RS512
-> public key, PEM/JWK, kid, JWKS, issuer
ES*/PS*
-> public key + algorithm/key compatibility + implementation support
The allowed algorithm set comes from application configuration. I never make the token header the policy source.
Exact error strings tell me which phase failed
I treat these differently:
JsonWebTokenError: invalid signature
JWSSignatureVerificationFailed
signature verification failed
InvalidSignatureError
kid not found
no applicable key found in the JSON Web Key Set
jwt malformed
jwt malformed fails before cryptography. kid not found is usually key selection. invalid signature normally means a candidate key was actually used and did not verify. That phase distinction prevents me from chasing secret encoding when the real problem is an empty JWKS lookup.
HMAC: I compare key bytes, not screenshots of configuration strings
For HS256, two systems can display the same-looking value while using different bytes.
I check:
- UTF-8 text vs Base64-decoded secret;
- trailing newline from a secret file or environment variable;
- quotes accidentally included in the secret;
- different secrets in dev/staging/prod;
- secret rotation;
- secrets that are too weak for the algorithm and threat model.
Example:
const rawUtf8 = new TextEncoder().encode(process.env.JWT_SECRET);
const decodedBase64 = Uint8Array.from(
atob(process.env.JWT_SECRET),
char => char.charCodeAt(0)
);
Those are different keys. The correct interpretation is whatever the signer and verifier contract specifies.
RSA: I distinguish SPKI, PKCS#1, certificate, and JWK
Common inputs include:
-----BEGIN PUBLIC KEY----- # usually SPKI
-----BEGIN RSA PUBLIC KEY----- # PKCS#1 public key
-----BEGIN CERTIFICATE----- # X.509 certificate containing a public key
Some libraries accept several forms; others expect a specific one. I convert deliberately rather than stripping PEM headers until a parser stops complaining.
If I have a JWK, I also inspect kty, alg, use, and key_ops when present.
kid not found is a JWKS/cache problem until proven otherwise
When a token carries:
{ "alg": "RS256", "kid": "key-2026-09" }
I look in the trusted issuer's current JWKS. Common causes of a miss are:
- verifier cache is stale;
- issuer has begun signing with a new key before verifiers saw it;
- old key was retired while old tokens remain active;
- wrong tenant or issuer environment;
- duplicate/ambiguous
kidhandling; - matching
kidexists butkty/algis incompatible.
I do not fetch an arbitrary key URL from untrusted token data and automatically trust it.
Re-serializing JSON can destroy a valid signature comparison
These payloads are semantically similar:
{"sub":"42","role":"user"}
{ "role": "user", "sub": "42" }
but they produce different serialized bytes. I always verify the original compact header.payload segments, not JSON I decoded and re-encoded.
This is also why manually changing exp, aud, or role necessarily changes the signed input.
A practical Node diagnostic with jsonwebtoken
With trusted local key material, a minimal reproduction can isolate the library/configuration layer:
import jwt from "jsonwebtoken";
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://login.example.com/",
audience: "api://orders"
});
console.log(payload);
} catch (error) {
console.error(error.name, error.message);
}
For an invalid signature test, I may temporarily remove issuer/audience options only in a local diagnostic to determine whether cryptography fails before claims. I do not deploy weaker validation as a fix.
A jose reproduction makes the key-selection boundary explicit
import { jwtVerify } from "jose";
const result = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://login.example.com/",
audience: "api://orders"
});
If the application normally uses remote JWKS, I reproduce with the same trusted issuer/JWKS relationship rather than a random public key that happens to verify.
How key rotation changes the diagnosis
Rotation introduces a timeline:
old tokens -> kid=K1
new JWKS -> K1 + K2
new tokens -> kid=K2
later -> K1 removed after policy allows
If K2 tokens fail, I ask whether K2 was published before signing started and whether unknown-kid triggers a controlled refresh. If K1 tokens fail, I ask whether K1 was removed before those tokens aged out.
The JWT key rotation guide covers cache propagation and overlap in detail.
Decision matrix: what I test next
| Symptom | Most likely layer | First check |
|---|---|---|
HS256 invalid signature | secret bytes | raw vs Base64, newline, environment |
RS256 invalid signature | public key | issuer-bound PEM/JWK |
kid not found | selection/cache | current trusted JWKS |
| algorithm mismatch | policy | configured allowlist |
| failure only after rotation | lifecycle/cache | K1/K2 overlap, refresh |
| failure after editing token | signed bytes | original compact JWT |
| signature passes, API still 401 | claims | exp/iss/aud/type |
Security shortcuts I explicitly reject
- disable signature verification;
- accept whatever
algappears in the header; - switch RS256/HS256 dynamically because one happens to pass;
- trust a token-provided key URL automatically;
- try every unrelated key until one verifies;
- weaken an HMAC secret for convenience;
- treat successful decoding as successful verification.
RFC 8725 is clear about constraining algorithms and key use rather than turning attacker-controlled metadata into policy.
My deterministic invalid-signature flow
- Preserve the original compact JWT.
- Decode
alg,kid,iss, andaudwith the JWT Decoder. - Confirm the application's allowed algorithms.
- Identify the expected issuer and its trusted key source.
- For HMAC, compare exact secret bytes and encoding.
- For RSA/EC, verify key format, key type, and provenance.
- For JWKS, check
kid, cache age, rotation, and ambiguity. - Verify the original
header.payloadbytes. - If cryptography succeeds, validate
exp,nbf,iss,aud, and token type with the JWT Validator. - If the API still rejects, use JWT Debugger to separate authentication from authorization.
An invalid signature is not a cosmetic parsing problem. It is evidence that the verifier cannot establish integrity/authenticity with the key path it was configured to trust. The fastest fix is to identify which input to verification differs, not to bypass the check.