JWT Invalid Signature: Every Cause and How to Fix It
A JWT invalid signature error means one thing: the signature your verifier computed does not equal the signature carried in the token. That is the whole message. It says nothing about expiry or permissions, and it is almost never a bug in your JWT library. Something about the bytes going into the HMAC, or the public key going into the verify call, differs between the side that signed and the side that checks.
Most of the time the culprit is the key material rather than the token. Use this to pick a starting point:
Which algorithm is in the header?
├─ HS256 / HS384 / HS512 → almost always a secret problem
│ ├─ signer and verifier are different languages? → Section 3
│ └─ same language, works locally, fails in prod? → Section 4
└─ RS256 / ES256 / PS256 → almost always key format or the wrong key
└─ → Section 7
Token travelled through a gateway, proxy, or copy-paste? → Section 6
Error appears only after some hours or on one host? → Section 8
Every section below ends with something you can run. The cheapest first move is to paste the token into the JWT decoder and read the alg field, since half the branches above collapse the moment you know it.
1. What “invalid signature” actually means
Different libraries print different strings for the same failure. Find yours in this list so you know you are in the right guide:
- Node
jsonwebtoken:JsonWebTokenError: invalid signature - Python
PyJWT:InvalidSignatureError: Signature verification failed - Java
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
All three fire at the same moment in the same code path. The library takes the first two segments of your token, recomputes the signature with the key you handed it, and compares the result byte for byte against the third segment. Not equal, throw.
The comparison is exact, and it carries no information about how different the two values are. A one-byte difference in the secret and a completely wrong key produce identical error messages. That is why the rest of this guide is about narrowing the input space rather than reading the error more carefully.
Note what has not happened yet when this error fires. Claim validation runs after signature verification, so exp, nbf, aud, and iss have not been looked at. If your JWT signature verification failed, the token’s contents are irrelevant to the diagnosis, though they are still readable, since a JWT is encoded rather than encrypted. Decoding the header and payload takes no key at all; see how to decode a JWT if you want the segment-by-segment walkthrough.
Two fields in the header decide where you go next: alg tells you whether you are hunting a shared secret or a key pair, and kid tells you which key the signer believed it was using.
2. The signature covers the encoded string, not your object
RFC 7515, the JSON Web Signature spec, defines the JWS Signing Input as the ASCII string:
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
The HMAC is computed over that string, not over your claims map and not over anything your language considers structured data. Here is the signing input used throughout this article, taken from the standard example payload:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
This is the part most developers have backwards, and it catches teams constantly: any layer that decodes the payload and re-encodes it destroys the signature. JSON serialization is not canonical. Key order changes when a map round-trips through most languages. Whitespace appears or disappears. Non-ASCII characters get escaped as \uXXXX by one serializer and emitted literally by another. Numbers get reformatted, so 1516239022 may come back as 1516239022.0. Each of those produces a different base64url string, so a different signing input, so a different signature.
Real triggers we have seen:
- An API gateway that parses the JWT to enrich it with a tenant ID and re-emits the token.
- A logging or tracing middleware that “normalizes” headers and rewrites the Authorization value.
- A developer who pretty-printed a token to read it, then pasted the pretty version back.
If any component between your signer and your verifier can rewrite the token, that component is the first suspect. Tokens are opaque strings in transit; the only safe operations are store, copy, and compare.
3. Same secret, different bytes
HMAC does not consume a string. It consumes bytes. Your config file, your secrets manager, and your environment variables all store strings. Something has to convert one to the other, and that conversion is not standardized across JWT libraries. Two services can hold character-for-character identical secrets and still compute different signatures, which is what sits behind most “the secret is literally identical, I diffed it” bug reports.
Here is the proof, computed locally against the signing input from Section 2. The secret string is 36 characters:
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| Byte interpretation | Bytes | What the key actually is | Resulting HS256 signature |
|---|---|---|---|
| Treated as UTF-8 text | 36 | the 36 visible characters themselves | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| Base64-decoded first | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
Same secret string, same algorithm, same payload, and two signatures with nothing in common. Whichever side got it “wrong” reports invalid signature, and no amount of diffing the config file will reveal anything, because the config files match.
The full token for the UTF-8 reading, if you want to reproduce this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Paste it into the JWT decoder with the secret above and it verifies. Decode the secret as base64 first and it does not.
How each library turns a string into key bytes
Stick to what is documented. The table below is deliberately narrow, and the last column matters more than the first.
| Runtime / library | String-to-bytes behaviour | Who decides |
|---|---|---|
Node jsonwebtoken | UTF-8 bytes of the string | the library |
Python PyJWT | UTF-8 bytes of the string | the library |
Java jjwt, legacy String overload | platform base64 codec, per jwtk/jjwt#204 | the library |
Go golang-jwt | takes []byte directly | you, at the call site |
| .NET | takes byte[] directly | you, at the call site |
The Java row is the historical source of the cross-stack pain, and it needs stating precisely. In old jjwt versions, signWith(SignatureAlgorithm, String) and its siblings ran the String through a base64 codec rather than taking its raw bytes, while the byte[] overloads used the bytes as given. A Node service and a Java service sharing one secret therefore disagreed. That String API has been deprecated since jjwt 0.10, and the modern form is explicit:
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
This is not “how Java does JWTs.” It is one library’s legacy overload, and current jjwt code that passes a byte[] has no ambiguity at all. The mirror-image report on the Node side is auth0/node-jsonwebtoken#208, where tokens signed in Java would not verify in Node. There are similar reports against PHP’s firebase/php-jwt (see firebase/php-jwt#153), though we have not verified that library’s byte handling ourselves, so treat it as a lead rather than a diagnosis.
Go and .NET belong in a different bucket. Neither library decides on your behalf; both hand you the []byte / byte[] parameter and step back. []byte(secret) and Encoding.UTF8.GetBytes(secret) yield UTF-8, while Convert.FromBase64String(secret) yields decoded bytes. The bug, when it happens, lives in your call site, which is good news: it is visible in your own diff.
Is my JWT secret base64 or UTF-8?
There is no flag in the token that tells you. You have to reason about the string itself:
- Does it use only
A–Z a–z 0–9 + / =(or-and_)? If so, it could be base64. A secret containing a space, a!, or a#cannot be. - Is its length a multiple of 4, or does it end in
=padding? Both are strong hints that something base64-encoded it on the way in. - Does base64-decoding it produce sensible bytes? Run it through the Base64 decoder. Readable ASCII or exactly 32 random-looking bytes suggests base64. Mojibake suggests the string was never encoded.
A secret like c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm trips all three tests, which is why it is dangerous: both readings are plausible. Secrets that contain a - or _ are ambiguous in a nastier way, since they are valid base64url but invalid standard base64.
When you cannot reason your way to an answer, compute both. Take the signing input and run HMAC-SHA256 over it twice in the HMAC generator, once with the secret as text and once with the decoded bytes, then compare each result against the token’s third segment. One of them will match, and that tells you which side of your system is right.
Characters are not bytes
The related trap is counting characters when the requirement is in bytes. RFC 7518 §3.2 states the key floor for HMAC-SHA in bits, not in characters, and encoded text expands:
| How you write it | Entropy | Equivalent bytes | For HS256 (needs ≥256 bit) |
|---|---|---|---|
| 32 hex characters | 128 bit | 16 bytes | below the floor |
| 32 base64 characters | 192 bit | 24 bytes | below the floor |
| 32 random bytes | 256 bit | 32 bytes | meets it (64 characters in hex, 44 in base64 with padding) |
A “32-character secret” can be anywhere from 128 to 256 bits depending on the alphabet. This is orthogonal to the byte-interpretation problem above, but it bites the same people, because a team that measures in characters is usually a team that never looked at the bytes. For the actual selection rules (length, encoding choice, rotation) go to the JWT secret generator; its reference notes cover them properly and there is no reason to duplicate them here.
4. The secret itself got polluted
Your two services agree on the byte interpretation. The signature still fails. Now check whether the secret each side loaded is the secret you think you wrote, because environment plumbing is remarkably good at adding a byte.
Trailing newline in .env. JWT_SECRET=abc followed by a line break can be loaded as abc\n by some readers. One extra byte, and HMAC produces an entirely unrelated output with no partial similarity to notice.
Quotes read as data. JWT_SECRET="abc" means abc to some loaders and "abc" to others, especially when the file is sourced by a shell versus parsed by a library. Docker Compose’s env_file and a .env parser can disagree on the same file.
Invisible characters from copy-paste. Copying a secret out of Slack, a wiki, or a PDF can drag in a zero-width space (U+200B, bytes e2 80 8b) or a non-breaking space (U+00A0, bytes c2 a0). Both are invisible in every editor and both change the HMAC.
CI and container mangling. Secrets passed through shell interpolation get $ expanded or backslashes eaten. Some CI systems trim values, others do not. Kubernetes secrets are base64 in the manifest and raw in the container, a double-decoding trap of its own.
The fix is to stop looking at the secret and start measuring it. On each side, print the length and a fingerprint, never the value:
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
Run both commands on the signer and the verifier and compare the two outputs. Matching length and matching fingerprint means the secret is not your problem, so go back to Section 3. A length that is one greater than you expect is the trailing newline. A length two greater is the quotes.
When the length is off and you want to see exactly what is in there, hex-dump it in a local shell against a development secret:
printf '%s' "$JWT_SECRET" | xxd
A 0a at the end is a newline. A leading and trailing 22 is a pair of quote characters. c2 a0 or e2 80 8b in the middle is the invisible-character case. Do not run this against a production secret on a machine that ships its terminal output anywhere.
The equivalent check inside a running Node or Python process:
const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))
In Python, len(s) counting fewer than len(s.encode("utf-8")) tells you there are non-ASCII characters in a secret that was supposed to be ASCII.
5. Algorithm and key type don’t match
The alg header and the key you pass have to belong to the same family. HS256 wants a shared secret, which is a byte string. RS256 and ES256 want an asymmetric key, which is a PEM or JWK. Cross those wires and the library may throw a clear type error or may just report invalid signature, depending on how forgiving it is.
Common versions of this:
- The header says
HS256, and the verifier hands the library a PEM public key. Some libraries HMAC the PEM text and report a signature mismatch. - The header says
RS256, and the verifier hands it the HMAC secret string. - The verifier passes no algorithm list at all and lets the library infer from
alg, so a config drift on the signing side silently changes what the verifier does.
That last one is where a configuration bug turns into a security bug, so pin the algorithm explicitly on every verify call:
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
Pinning also converts vague signature errors into precise ones. If a token arrives with alg: RS256 and your allowlist says HS256, you get an explicit algorithm error naming both values.
Worth drawing a line here. What this section describes is misconfiguration: your own two components disagreeing, with no adversary involved. There is a related failure with the same shape where an attacker rewrites alg from RS256 to HS256 and signs with your public key as the HMAC secret. That is algorithm confusion, it is an attack rather than a bug, and it is covered in JWT security best practices along with the rest of the threat model. The defense, an explicit allowlist, happens to be the same, which is a good argument for applying it even when you are only chasing a bug.
6. The token changed in transit
Before blaming keys, confirm the verifier received the same string the signer produced. A JWT is fragile in exactly the ways strings are fragile.
The Bearer prefix. Authorization: Bearer eyJhbGci... is a header value, not a token. Splitting on the wrong thing, or splitting once and keeping the wrong half, leaves you verifying Bearer eyJhbGci... or an empty string. Strip it deliberately:
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
Whitespace and line breaks. Tokens copied from a terminal wrap. Tokens stored in YAML get folded. A single embedded \n inside the third segment produces a signature mismatch, not a parse error, because base64url decoders often skip whitespace while the string comparison does not.
URL encoding. A token that travelled as a query parameter may come back with . as %2E, or with - and _ translated by an over-eager encoder. Decode once, exactly once.
Truncation. Cookies cap at roughly 4 KB each, and RS256 tokens with a few claims routinely exceed that. A truncated token usually fails base64 decoding, but if it is cut on a 4-character boundary you get a valid-looking token with a wrong signature instead.
Two commands settle this. A well-formed JWT has exactly two dots:
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
And every character must be in the base64url alphabet, so this should print nothing at all:
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
Any output from the second command names your problem: 3d is = padding that should not be there, 2b or 2f are standard-base64 + and / where base64url expects - and _, and 20 is a stray space.
7. RS256 and ES256 specific failures
Asymmetric algorithms swap the secret problem for a key-management problem, and the failure modes barely overlap with the HS256 ones.
PKCS#1 versus PKCS#8. These are two container formats for the same RSA key, and they are visually distinguishable by one word in the header line:
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
Libraries vary in which they accept. When one rejects the format outright you get a clear error; when it half-parses you can get a signature that never verifies. Convert rather than fight it:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
The keys are swapped. Signing with the public key, or verifying with the private one. Obvious in principle, easy to do when both files sit in the same directory under names differing by four characters. Verify which is which:
openssl rsa -in key.pem -noout -text | head -1
A private key prints its modulus size as a private key; a public key errors out unless you add -pubin.
JWKS and kid drift. With a JWKS endpoint, the verifier picks a key by matching the token’s kid against the key set. Three things go wrong here: the signer rotated and the verifier’s cached JWKS is stale; the token has no kid and the verifier picks the first key in the set; or two environments publish overlapping kid values. When you suspect this, fetch the JWKS fresh and confirm the exact kid from the token header is present in it.
ES256 signature encoding. ECDSA signatures are a pair of integers, r and s, and there are two ways to serialize them. General-purpose crypto stacks often emit DER, a variable-length ASN.1 structure. RFC 7518 §3.4 requires the JOSE form instead: r and s each padded to a fixed length and concatenated, which is 64 bytes for P-256. A DER signature dropped into a JWT has the wrong length as well as the wrong bytes, so an ES256 token whose third segment does not decode to exactly 64 bytes was built by something that skipped the conversion.
To isolate whether the problem is your key or your pipeline, sign the same payload independently in the JWT encoder and compare the output against what your service produced. Identical signatures point at transport or claim handling. Different signatures point at the key.
8. Errors that look like signature failures but aren’t
Some of these are genuinely mislabelled by the libraries themselves, which is how they end up in the wrong bug report.
| Symptom | What it actually is | Where to look |
|---|---|---|
PyJWT ExpiredSignatureError | exp is in the past. The name says signature; the cause is a claim. | Clock skew between hosts, or a too-short TTL |
PyJWT ImmatureSignatureError | nbf is in the future | The signer’s clock is ahead of the verifier’s |
Node TokenExpiredError | exp is in the past | Same as above |
| Generic 401, no detail | Framework collapsed every verification failure into one response | Turn on library-level error logging |
| Works for a few minutes, then fails | Token expiry, not signature | Compare iat and exp against both hosts’ clocks |
| Fails for one audience only | aud or iss mismatch | The verifier’s expected audience list |
PyJWT’s naming is the standout trap. ExpiredSignatureError contains the word “signature” but is raised during claim validation, long after the signature has already verified successfully. Searching the error string leads straight to signature-troubleshooting material, and hours disappear into the wrong section of the problem.
Clock skew produces the most confusing pattern of all: intermittent failures correlating with nothing in your code. If one host’s clock drifts ahead, freshly issued tokens fail nbf or iat validation on arrival, and the failures wander as the drift grows. Compare date -u on both machines first. Most libraries accept a leeway parameter, which is the right fix for skew you cannot eliminate and the wrong fix for a clock that is genuinely broken.
The general rule: a failure that depends on the clock, on the host, or on the audience is not a signature problem. Signature failures are deterministic, and the same token and key fail the same way forever.
9. A repeatable troubleshooting workflow
Run these in order. Each step either finds the bug or eliminates a branch, and stopping early is the point.
- Decode the header. Paste the token into the JWT decoder and record
algandkid. This decides everything downstream and needs no key. - Check the token’s shape. Exactly two dots, base64url characters only, no
Bearerprefix, no whitespace. Use the two commands in Section 6. Eliminates transport corruption. - Pin the algorithm on the verify call. If a mismatch exists between
algand your allowlist, you now get an explicit error naming both instead of a generic one. - Fingerprint the key on both sides. Print byte length and a truncated SHA-256 on the signer and the verifier, as in Section 4. Different values mean the plumbing is at fault and you never reach step 5.
- If the two sides are different languages, resolve the byte interpretation. Consult the table in Section 3, decide explicitly whether the secret is text or base64, and make both sides say so in code rather than by default.
- Re-sign the same payload independently. Use the JWT encoder with the key you believe is correct and compare its third segment against your token’s. A match means your signing side is fine and the verifier is the problem.
- Cross-check the HMAC by hand. Run the signing input through the HMAC generator with both byte interpretations. Whichever matches the token tells you which side to change.
If you get through all seven and still need help, most bug reports stall because they omit the facts that determine the answer. Include these:
- The
algvalue from the header, and whether akidis present - Language, library, and exact version on both the signing and verifying side
- The byte length of the secret on both sides, and the first 16 hex characters of its SHA-256 (never the secret itself)
- Whether the secret is stored as text or base64, and how each side converts it
- The full signing input. The first two segments are not sensitive; anyone holding the token can read them anyway
- For RS256 and ES256: the PEM header line, verbatim
That list turns an unanswerable “my JWT signature does not match” into a question someone can actually resolve, usually in one reply.
FAQ
Why does the same secret work in one language but fail in another?
Because libraries disagree on how to turn a secret string into key bytes. Node jsonwebtoken and Python PyJWT use UTF-8; jjwt’s legacy String overload used a base64 codec (jwtk/jjwt#204); Go and .NET leave the decision to your call site. Same characters, different bytes, so a different HMAC.
Does the signature cover the decoded payload or the encoded string?
The encoded string. RFC 7515 defines the signing input as base64url(header) + "." + base64url(payload) as literal ASCII. Any layer that deserializes the payload and re-serializes it changes key order, whitespace, or number formatting, producing a different string and therefore a different signature.
My secret looks like base64. Should I decode it before signing?
Only if the other side does too. There is no correct answer in isolation; the requirement is that both ends agree. Check whether the string uses only base64 characters and has a length that is a multiple of four, then make the choice explicit in code on both sides rather than relying on defaults.
Can a trailing newline in .env really break the signature?
Yes. HMAC consumes bytes, and abc\n is four bytes where abc is three. The resulting signature shares nothing with the correct one. Print printf '%s' "$JWT_SECRET" | wc -c on both hosts; a length one greater than expected is almost always this.
How do I tell whether it’s the secret or the algorithm?
Read alg from the header first. If it starts with HS, you need a shared secret and a PEM will fail. If it starts with RS, PS, or ES, you need a key pair and a secret string will fail. Once alg and key type belong to the same family, remaining failures are key-content problems.
Why does jwt.io say the signature is valid but my server rejects it?
Because the online tool and your server may interpret the secret differently: one as UTF-8 text, the other as base64. The tool validates against the bytes it derived, not the bytes your server derived. Also, never paste production secrets into an online tool you do not control; use a development key.
Is “invalid signature” ever caused by an expired token?
No. Signature verification runs before claim validation, so expiry is never the cause. Expiry surfaces separately as TokenExpiredError in Node or ExpiredSignatureError in PyJWT, whose name is misleading: the signature verified fine and only exp failed.
Conclusion
Signature mismatches are almost never a cryptography problem. HMAC-SHA256 works. RSA works. What fails is the boundary where a string becomes bytes: a base64 codec on one side and UTF-8 on the other, a newline that a config loader kept, a payload that a gateway helpfully re-serialized. Every cause in this guide is a disagreement about bytes.
So make the bytes explicit and stop relying on defaults. Write down, in your team’s documentation, whether the shared secret is stored as raw text or as base64, and have every service convert it the declared way instead of inheriting whatever its library assumed. For systems spanning multiple languages, store secrets in hex or base64 and decode them explicitly at every call site: one line per service, and the ambiguity disappears. Then add the byte-length fingerprint from Section 4 to your health check, so the next mismatch shows up as a startup warning rather than a production 401.