Notes
JsonWebTokenError: invalid signaturewrong secret, wrong key, or the wrong algorithm
Your Node backend calls jwt.verify() from the jsonwebtoken package, and instead of a payload it throws this:
JsonWebTokenError: invalid signatureThe token was not rejected for being malformed, expired or meant for another audience. jsonwebtoken got further than that: it split the token into its three parts, read the header, accepted the algorithm in it and the kind of key you passed, then checked the third part, the signature, over the first two parts with your key, and they did not match. So one of two things is true. The key your verifier holds is not the key that signed the token, or the token is not the one that was signed. The message cannot tell you which, and it never shows either key.
Other tools say the same thing in other words. jose throws JWSSignatureVerificationFailed with the message "signature verification failed", PyJWT raises InvalidSignatureError with "Signature verification failed", and the jwt.io debugger shows "Invalid Signature". The causes below apply to all of them. The code uses jsonwebtoken, because this exact message comes from it.
The title names the algorithm too, because it is a common first suspect. In jsonwebtoken 9 a wrong algorithm usually gets its own message instead, and section 05 shows which one.
What the message already rules out
jsonwebtoken runs its checks in a fixed order and stops at the first one that fails. Every earlier check has its own message, so getting to invalid signature tells you what has already passed. The order below is from verify.js in version 9, the current major version since December 2022.
invalid signature means steps 1 to 5 passed: the token is well formed and its alg fits the kind of key you passed. What is left is a difference in bytes, in the secret or key, or in the header and payload the signature covers. The order and the messages are taken from verify.js in the jsonwebtoken repository; the two mismatches in step 6 are illustrations.Two things follow from the order. First, the algorithm is rarely the problem: an RS256 token checked with an HMAC secret stops at step 4 or 5 and never reaches the signature. Second, the claims have not been read yet. The expiry is checked only after the signature, so a token that is expired and signed with another key still says invalid signature.
Step 6 is plain arithmetic over bytes. RFC 7515 defines the signing input as the base64url header, a dot and the base64url payload, exactly as they appear in the token. For HS256 the verifier computes an HMAC with SHA-256 over those characters, keyed with its secret (RFC 7518, section 3.2). For RS256 and ES256 it checks the signature with the public key. One different byte in the key, or in the first two parts of the token, gives a different result, and nothing in the output says which byte it was.
01
The verifier's secret is not the signer's secret
With HS256, HS384 and HS512 the same secret signs and verifies. jsonwebtoken reads a string secret as UTF-8 bytes, so both sides agree only if the two strings are identical, character for character, including characters you cannot see. The 2021 question linked below comes down to this, and so do several answers on the 2018 one (its highest-voted answer is about public keys, section 03). It shows up in one of these forms:
- Signed with one value, verified with another. In the 2021 question (about 86,000 views) the login route signed with the literal string
'secret'and passed the environment variable as a fourth argument, wherejwt.sign()expects a callback. The middleware verified withprocess.env.JWT_TOKEN_SECRET. The accepted answer: sign with the variable, not the word. - The variable's name instead of its value. A 2024 answer on the same question had written
'process.env.JWT_SK'in quotes, so the code used that literal text as the secret. - The secret changed, the token did not. A token signed before you changed
JWT_SECRETnever verifies against the new value. It keeps coming back from browser storage, a cookie or a saved Postman request until someone signs in again, which is how one answer on that question got rid of the error. - Two processes, two values. The service that signs and the service that verifies read different
.envfiles, or a staging token reaches production. Each side is consistent with itself, and they still disagree. - An invisible character. A trailing newline or space that got into one environment is part of the key. HMAC uses every byte of the secret.
Two files, two secrets
// routes/login.js
const token = jwt.sign(payload, 'secret', { expiresIn: '24h' });
// middleware/verify-token.js
const verified = jwt.verify(token, process.env.JWT_TOKEN_SECRET);
One module owns the secret
// auth/secret.js
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET is not set');
module.exports = secret;
// login and middleware both use it
const secret = require('../auth/secret');
const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '24h' });
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
The check at the top matters because a missing variable is a different failure: jsonwebtoken then says secret or public key must be provided. If you are looking at invalid signature, the variable has a value. It is just not the same value on both sides.
02
The secret is base64, and only one side decodes it
Some systems generate an HMAC secret as random bytes and show it to you base64-encoded. If the signer used the decoded bytes and your verifier passes the base64 text, the keys differ even though you copied the value exactly. jsonwebtoken's README describes this case: other libraries expect base64-encoded secrets, and for those you pass Buffer.from(secret, 'base64').
Verify with the decoded bytes
const claims = jwt.verify(token, Buffer.from(process.env.JWT_SECRET, 'base64'), {
algorithms: ['HS256'],
});
It breaks in the other direction too: if the signer used the text, decoding it on your side is the mistake. Try the other form once. If the error disappears, you know which side decodes the secret and which one does not. On the 2018 jwt.io question, one answer got the token verified only after ticking the debugger's option for a base64-encoded secret.
03
The public key is not the one that signed this token
With RS256, PS256 and ES256 the issuer, for example Firebase or Supabase, signs with a private key you never see, and you verify with the matching public key. Issuers publish their current public keys, each with an ID, and the token's header names the one that signed it in kid. Verify with a key you saved once, or with the key of another project, tenant or environment, and the check fails with invalid signature.
- Firebase ID tokens. Firebase's documentation requires
algto beRS256andkidto match one of the public keys athttps://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com, refreshed according tomax-agein theCache-Controlheader of that response. The Admin SDK'sverifyIdToken()does all of this for you. The same page warns that these checks are meant for ID tokens from the client SDKs, not for custom tokens you create with the Admin SDK. - Supabase. Projects that moved from the legacy JWT secret to asymmetric signing keys publish the public keys at
https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json, each with itskidandalg. Supabase's migration guide warns that code verifying every JWT against the legacy secret, with jose, jsonwebtoken or similar, can break once you rotate. With jsonwebtoken that break usually reads invalid algorithm, because an ES256 or RS256 token now meets an HMAC secret. While a project still uses only the shared secret, the key set is empty. - Anything with a key set. Pick the key by
kidinstead of hardcoding one. With jsonwebtoken, pass a function as the key and let jwks-rsa fetch it, as the jsonwebtoken README shows. With jose,createRemoteJWKSetdoes the lookup, as in Supabase's documentation:
jose: the key set picks the key by kid
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS);
The 2022 question about this error shows the confusing version: an RS256 key pair generated with Node's crypto, a token that jwt.io verifies with those keys, and jsonwebtoken still answering invalid signature. It has no answers. Signature verification is deterministic: given the same token and the same key, a correct debugger and jsonwebtoken agree. When they disagree, compare the inputs first. Log the exact token string your middleware receives and a fingerprint of the key it loaded (see the checks below), and compare both with what you pasted.
04
The token changed after it was signed
The signature covers the header and the payload exactly as they appear in the token, as base64url text (RFC 7515, section 5.2, step 8). Change one character and the check fails, which is the whole point of a signature. Ways this happens without any attacker:
- You edited the payload in a debugger, to try another role or user, and sent the edited token to your API.
- Code decoded the token, changed or reordered a claim and encoded it again. The same claims with different spacing or key order are different bytes. To change a claim, sign a new token.
- The signature part was cut short on the way. A shortened HS, RS or PS signature fails as invalid signature. For ES256, ES384 and ES512 jsonwebtoken throws a TypeError instead, such as
"ES256" signatures must be "64" bytes, saw "30"(see 05). Damage in the header or payload usually fails earlier, as invalid token or a JSON parse error.
A Bearer prefix left in front of the token is not this case either: jsonwebtoken reports it as invalid token, because the first part no longer decodes.
05
When it really is the algorithm
A wrong algorithm is a real cause, but jsonwebtoken mostly reports it with a different message. That is useful, because the message tells you which of the three problems you have:
invalid algorithm: the header'salgis not on the allowed list. Without analgorithmsoption, jsonwebtoken derives the list from the key: a secret allows HS256, HS384 and HS512, an RSA key the RS and PS algorithms, an EC key the ES ones. An RS256 token checked with a secret lands here, and so does an HS512 token when you allowed only HS256.secretOrPublicKey must be an asymmetric key when using RS256, ora symmetric key when using HS256: you setalgorithmsyourself, and the key is the wrong kind for the token."ES256" signatures must be "64" bytes, saw "71", a TypeError that compares the signature's length with the one the algorithm needs: 64 bytes for ES256, 96 for ES384, 132 for ES512. RFC 7518 requires that fixed-size concatenation of R and S. A larger number usually means a DER-encoded signature: Node'scrypto.sign()produces DER unless you passdsaEncoding: 'ieee-p1363', so this points at a token signed by hand, or by code that skipped the conversion. A smaller number means the signature was cut short on the way (see 04).
One key problem turns into an algorithm message: a PEM public key that Node cannot parse. jsonwebtoken 9 first tries to read a string key as a public key, and if that fails it uses the string as an HMAC secret without saying so. A PEM kept in an environment variable with its line breaks written as the two characters \n gets there, and the result is invalid algorithm, or the asymmetric-key message if you pinned RS256. Turn the escapes back into line breaks before you pass the key:
A PEM from an environment variable, with real line breaks
const publicKey = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, '\n');
const claims = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
If you pinned algorithms to the one you expect and still see invalid signature, the algorithm is settled. Go back to sections 01 to 04.
Check which one you have, and that the fix worked
- Read the header first.
jwt.decode(token, { complete: true }).headershowsalgand, for keys from a key set,kid. HS means a shared secret; RS, PS and ES mean a public key.decode()verifies nothing, so use it only to look. - HS: compare the secret on both sides without printing it. Run this once in the process that signs and once in the process that verifies, then delete it:
Same length and the same 12 characters means the same secret. A length that is off by one or two points at a newline, a space or quotes. If one side decodes the secret as base64, hash the decoded bytes on both sides instead.const crypto = require('crypto'); const s = process.env.JWT_SECRET; console.log(s.length, crypto.createHash('sha256').update(s).digest('hex').slice(0, 12)); - HS: round trip in the verifying process. Sign a fresh token there with the same secret and verify it right away. If that passes and the real token fails, the real token was signed with a different secret, or was changed after signing (see 04).
- RS, PS, ES: find the
kidin the issuer's key set. Open the Firebase key URL or your Supabasejwks.jsonand look for the token'skid. No match there means the token was signed by another project or environment, or with a key that is no longer published. A match there while your code still fails means your code verifies with an older copy of the keys. - Pin the algorithm and run the real token again. Call
jwt.verify(token, key, { algorithms: ['HS256'] })with the algorithm you expect. A fixed setup returns the payload, or fails withjwt expiredorjwt not active(or an audience or issuer message, if you pass theaudienceorissueroption). Those come after the signature (step 7 in the diagram), so seeing one of them means the signature now passes.
Traps
- Making the error disappear instead of fixing it.
jwt.decode()returns the payload without checking the signature, and the jsonwebtoken README says not to use it for untrusted messages. A route that accepts anything that decodes accepts tokens anyone can write. - Allowing every algorithm. Leaving
algorithmsopen, or allowing HS256 and RS256 against the same key, is the opening for algorithm confusion attacks. RFC 8725 says each key must be used with exactly one algorithm. jsonwebtoken 9 fixed its own versions of this (CVE-2022-23540 and CVE-2022-23541 in its changelog), but pin the list anyway. - Trusting one line of the README. An example in jsonwebtoken's README says an algorithm mismatch gives invalid signature. The code in 8.5.1 and in 9 returns invalid algorithm. Go by the message you actually get.
- Caching keys forever. Firebase tells you when to refresh through
max-age. Supabase caches its key set for 10 minutes at the edge and asks you not to cache it longer in your app, or to have a way to purge that cache when keys rotate. Keys loaded once at startup fail on the first token signed with a newer key. - Printing the secret to compare it. Logs get copied, shipped and kept. Compare fingerprints as above, and rotate a secret that ended up in a log, a screenshot or a chat.
Check your token in the browser
JWT Doctor decodes the token, shows alg and kid, and verifies the signature in your browser with Web Crypto against what you paste: a shared secret, a public key PEM (-----BEGIN PUBLIC KEY-----) or a JWK. The token and the key never leave your browser. Other PEM forms need one conversion first: the Firebase key URL returns X.509 certificates (-----BEGIN CERTIFICATE-----), and some tools write RSA keys as -----BEGIN RSA PUBLIC KEY-----. In Node, crypto.createPublicKey(pem).export({ type: 'spki', format: 'pem' }) turns either into the public key PEM the tool reads. It also flags a secret pasted for an RS256 or ES256 token (and the other way round), a Supabase project API key where a user access token was expected, and a Firebase custom token where an ID token was expected. It reads a pasted secret as text, like a string passed to jwt.verify(), so for an HS token that verifies there and fails in your code, your code is not using that text, or is not receiving that exact token (see 04). Use a test token and a test secret where you can.
Questions
Why does jwt.io say "Invalid Signature" when my server accepts the token?
Decoding needs no key, because the header and payload are only base64url, so the debugger shows them for any token. Checking the signature needs the same secret or public key your server uses. Answers on the 2018 question (about 135,000 views) list what went wrong there: the secret field left empty or holding a different value, a secret that had to be read as base64, and for public-key algorithms a debugger that could not fetch the issuer's keys, for example from a server on localhost. Use a test token and a test secret for this where you can.
Does invalid signature mean my token is expired?
No, and it says nothing about the other claims either. jsonwebtoken checks the signature before nbf and exp, so a token that is expired and signed with another key reports invalid signature. Fix the key first. If the token is also expired, the error then changes to jwt expired.
I changed JWT_SECRET. Why does every user get invalid signature now?
Every token signed with the old secret fails against the new one, so those users have to sign in again, or the verifier has to accept both secrets for a while. jsonwebtoken can take a function instead of a key when you call verify with a callback, and that function receives the token's header, so it can pick the secret by kid if you signed with the keyid option. Supabase's asymmetric signing keys handle this for you: after a rotation, non-expired tokens signed with the previous key are still accepted.
Can I use jwt.decode() until I find the cause?
Not for anything that makes a decision. jwt.decode() returns the payload without checking the signature, and the jsonwebtoken README says not to use it for untrusted messages. Anyone can write a payload that decodes.
Does invalid signature mean someone tampered with the token?
It means the bytes and the key do not match, and the error cannot tell you why. In the answered questions linked on this page the cause was configuration: a different secret, a secret that had to be read as base64, a key the debugger did not have. Reject the token either way: RFC 7515 says a JWS whose signature does not validate must be considered invalid.
Sources
- auth0/node-jsonwebtoken, README, "Errors & Codes" (the JsonWebTokenError messages, including invalid signature, and NotBeforeError "jwt not active"):github.com/auth0/node-jsonwebtoken#errors--codes; the same README on
jwt.sign(callback as the fourth argument,keyid),jwt.verify(a string secret is UTF-8, theBuffer.from(secret, 'base64')note, default algorithms by key type, a key function with jwks-rsa, audience and issuer checked only when you pass those options, the alg mismatch example comment) andjwt.decode(does not verify, not for untrusted messages): github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback - auth0/node-jsonwebtoken,
verify.jsin 9.0.3 (the order of checks and every message in the diagram; a string key is tried as a public key first, then used as a secret): github.com/auth0/node-jsonwebtoken/blob/master/verify.js;verify.jsin 8.5.1 (invalid algorithm for an alg outside the list): github.com/auth0/node-jsonwebtoken/blob/v8.5.1/verify.js; CHANGELOG (9.0.0 on 2022-12-21, key types must match the algorithm, CVE-2022-23540 and CVE-2022-23541): github.com/auth0/node-jsonwebtoken/blob/master/CHANGELOG.md - auth0/node-jws,
verify-stream.js, used by jsonwebtoken to decode (the payload is parsed as JSON only when the header'stypis JWT, so a payload that is not JSON throws a SyntaxError there): github.com/auth0/node-jws/blob/master/lib/verify-stream.js - Brightspace/node-ecdsa-sig-formatter, used by jsonwebtoken through jws and jwa (the "signatures must be N bytes" TypeError, thrown for any other length, with 64, 96 and 132 bytes for ES256, ES384 and ES512): github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/ecdsa-sig-formatter.js
- RFC 7515, JSON Web Signature, section 5.2 "Message Signature or MAC Validation" (the signing input is the encoded header, a period and the encoded payload; a JWS whose signature does not validate must be considered invalid): rfc-editor.org/rfc/rfc7515#section-5.2
- RFC 7518, JSON Web Algorithms, section 3.2 "HMAC with SHA-2 Functions": rfc-editor.org/rfc/rfc7518#section-3.2; section 3.4 "Digital Signature with ECDSA" (R and S concatenated: a 64-octet sequence for ES256, 96 for ES384, 132 for ES512): rfc-editor.org/rfc/rfc7518#section-3.4
- RFC 8725, JSON Web Token Best Current Practices, section 3.1 "Perform Algorithm Verification" (each key used with exactly one algorithm): rfc-editor.org/rfc/rfc8725#section-3.1
- Firebase, "Verify ID Tokens" (alg RS256, kid from the securetoken key URL, refresh by Cache-Control max-age, not for custom tokens, verifyIdToken in the Admin SDK): firebase.google.com/docs/auth/admin/verify-id-tokens; the key URL itself (a JSON object of kid to X.509 certificate, each
-----BEGIN CERTIFICATE-----): googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com - Supabase, "JWT Signing Keys" (migrating from the legacy JWT secret, code that verifies against it with jose or jsonwebtoken can break, previously used keys keep verifying after a rotation, ES256, RS256 and HS256): supabase.com/docs/guides/auth/signing-keys; "JSON Web Token (JWT)", section "Verifying a JWT from Supabase" (the jwks.json endpoint, empty without asymmetric keys, 10 minutes of edge cache, the jose example): supabase.com/docs/guides/auth/jwts#verifying-a-jwt-from-supabase
- Node.js, "Crypto":
crypto.createPublicKey()(PEM keys and X.509 certificates): nodejs.org/api/crypto.html#cryptocreatepublickeykey;keyObject.export()(type: 'spki',format: 'pem'): nodejs.org/api/crypto.html#keyobjectexportoptions;crypto.sign()(dsaEncoding, DER by default): nodejs.org/api/crypto.html#cryptosignalgorithm-data-key-callback;crypto.createHash(): nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options - panva/jose,
JWSSignatureVerificationFailed("signature verification failed"): github.com/panva/jose/blob/main/src/util/errors.ts; jpadilla/pyjwt,InvalidSignatureError("Signature verification failed"): github.com/jpadilla/pyjwt/blob/master/jwt/api_jws.py - Stack Overflow, "Always getting invalid signature in jwt.io" (2018, jsonwebtoken, about 135,000 views; the highest-voted answer: paste the issuer's public key from its JWKS; other answers: the secret field left empty, the wrong secret, the base64 option, the debugger unable to fetch keys from a localhost issuer or without CORS headers): stackoverflow.com/questions/50774780
- Stack Overflow, "How to solve JsonWebTokenError "invalid signature" after assigning some infos to the generated token?" (2021, Express, about 86,000 views; the accepted answer finds the literal 'secret' in jwt.sign; later answers: the variable name in quotes, logging in again for a fresh token): stackoverflow.com/questions/65882838
- Stack Overflow, "How do i solve JsonWebTokenError: invalid signature RS256" (2022, RS256 keys from Node's crypto, verified in jwt.io, no answers): stackoverflow.com/questions/74402740