Why Your JWT Token Is Invalid — And How to Fix It

You know the drill. It is late. You just deployed. Everything worked in staging. And now your Slack is lighting up with screenshots of this:

JsonWebTokenError: invalid signature

Or the even less helpful:

Invalid JWT Token

No stack trace. No hint about what went wrong. Just "invalid."

If you work with OAuth, API gateways, microservices, Next.js middleware, Express auth, or mobile APIs, you have probably burned hours debugging JWTs that looked perfectly fine. The problem is that a dozen completely different root causes all surface the same generic error message. This guide walks through every one of them, along with the debugging workflow that actually finds the problem.


JWT Structure Refresher

A JWT is three Base64URL-encoded segments joined by dots:

header.payload.signature

That is it. Three strings. Two dots.

The header says which algorithm signed it. The payload holds the claims (expiration, issuer, user ID, whatever you put there). The signature is what makes it trustworthy -- it proves the first two parts were not tampered with.

Because each segment is Base64URL-encoded, anyone can decode the payload. JWTs are not encrypted by default. I wrote about this misconception in detail, but the short version is: the signature gives you integrity, not secrecy. Do not put passwords, credit card numbers, or internal IDs in a JWT payload.

If you need to quickly peek inside a token during debugging, the JWT Decoder on this site lets you paste a token and see the decoded header and payload immediately -- no terminal, no script, no waiting.


Why "Invalid JWT" Errors Are So Hard to Diagnose

A JWT fails verification when any of these are true:

  • The token was modified after signing
  • The signature does not match what the server expects
  • The token expired
  • The structure is malformed (wrong number of segments, corrupted dots)
  • The secret key or public key is wrong
  • The algorithm in the header does not match what the verifier expects
  • The token was corrupted during transport

The libraries we use -- jsonwebtoken in Node, PyJWT in Python, firebase/php-jwt, the jjwt Java library -- all throw roughly the same error for every one of these. That is the core frustration. You see "invalid signature" and you have no idea whether the secret is wrong, the algorithm is wrong, or someone accidentally added a newline to the token in an environment variable.

The only way to fix it is to work through each possibility methodically. Let us do exactly that.


1. Invalid Signature

This is the most common JWT error, and it almost never means what people think it means.

Secret Mismatch Between Environments

A production story that most teams have lived through at least once:

You are developing locally. Your .env file has:

JWT_SECRET=dev-secret-key-12345

Your CI/CD pipeline injects the real production secret. The frontend dev (or you, on a Friday afternoon) accidentally pushes a token generated against the dev secret. That token hits production. The production server tries to verify it with the production secret. Boom -- invalid signature.

The fix is trivial once you find it. The trick is remembering to look there. Always check whether the token was generated by the same service and the same secret that is trying to verify it. In microservice architectures, token generation and token verification often happen in different codebases with different environment variable setups. Every service that verifies tokens needs the exact same secret.

Algorithm Mismatch

Signing code:

const token = jwt.sign({ userId: 42 }, process.env.JWT_SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m'
});

Verifying code (in a different service, written months later by someone else):

const decoded = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS512']
});

HS256 and HS512 are both HMAC algorithms, but they produce completely different signatures. The verify call rejects the token because the token header says HS256 and the verifier only accepts HS512.

Always pass the algorithms option explicitly when verifying. Relying on the library default means you are one refactor away from a production outage.

Public Key Formatting for RS256/ES256

When you use asymmetric algorithms (RS256, ES256, EdDSA), the private key signs and the public key verifies. The most common footgun here is PEM formatting.

This is wrong:

-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...

This is correct:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----

If your public key gets stored as a single-line string in Kubernetes secrets, AWS Parameter Store, or a .env file, verification will fail because the PEM parser cannot find the proper boundaries. The error message will still say invalid signature.

The fix is to ensure newlines are preserved when the key is injected into the runtime environment. If your deployment system strips newlines, you need to restore them at startup:

const publicKey = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, '\n');

2. Token Expired

Expiration is the second most common reason tokens fail. The error is usually more specific -- TokenExpiredError: jwt expired -- so it is easier to identify. But the root cause is not always obvious.

The Overnight Dev Cycle

A developer logs into the app on Tuesday afternoon. The access token is stored in localStorage with a 15-minute expiration. The developer closes their laptop at 5 PM. Comes back Wednesday at 9 AM. Every API call fails with a 401.

The token expired 16 hours ago.

The fix is straightforward: implement a refresh token flow. Short-lived access tokens (5--15 minutes) paired with longer-lived refresh tokens (hours or days) let you keep the security benefit of brief access tokens without making users log in every few minutes.

Decoding the exp Claim

To check if a token is expired, decode the payload and look at exp:

{
  "sub": "user_42",
  "iat": 1748000000,
  "exp": 1748000900
}

exp is a Unix timestamp in seconds. Convert it:

const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
const expiresAt = new Date(payload.exp * 1000);
console.log('Token expires:', expiresAt.toISOString());
console.log('Time left:', Math.floor((payload.exp * 1000 - Date.now()) / 1000), 'seconds');

Clock Skew Between Servers

If your auth server's clock is 30 seconds ahead of your API server's clock, tokens that the auth server considers valid will appear expired to the API server. This is rare in cloud environments (NTP usually keeps things synced), but it happens in on-prem deployments and during daylight saving transitions.

Most JWT libraries let you set a clock tolerance:

const decoded = jwt.verify(token, secret, {
  clockTolerance: 60 // allow up to 60 seconds of clock skew
});

3. Malformed JWT Structure

A valid JWT has exactly three dot-separated segments. Anything else fails before verification even starts.

Wrong Number of Segments

// Two segments -- missing signature
xxxxx.yyyyy

// Four segments -- something got concatenated
xxxxx.yyyyy.zzzzz.extra

Both produce a "malformed" or "invalid token" error.

Authorization Header Extraction Bugs

This is the single most common bug in Express/Koa/Fastify middleware. I have fixed it in at least five different codebases.

Broken extraction:

// req.headers.authorization is "Bearer eyJhbGciOi..."
const token = req.headers.authorization;
jwt.verify(token, secret); // fails -- "Bearer " prefix is included

Correct extraction:

const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  throw new Error('Missing or malformed authorization header');
}
const token = authHeader.split(' ')[1];

Even better, handle edge cases:

const extractToken = (header) => {
  if (!header) return null;
  // Some proxies lowercase the header name
  const parts = header.trim().split(/\s+/);
  if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
    return parts[1];
  }
  return null;
};

Duplicated "Bearer" Prefix

I have seen this in production logs:

Authorization: Bearer Bearer eyJhbGciOi...

This happens when a frontend dev manually prefixes "Bearer" to a token that already has it, or when a gateway adds the prefix and the downstream service also adds it. The result is a token that starts with Bearer and contains a space -- which means it is not a valid Base64URL string.

Token Truncation in Transit

JWTs can be large (especially with many claims or when using RS256). If a token exceeds ~4KB, it might get truncated by:

  • HTTP header size limits on some proxies (NGINX default: 8KB for all headers combined, but some older configs are lower)
  • Cookie size limits (browsers cap individual cookies at ~4KB)
  • URL query string limits (if you are passing JWTs as query parameters -- which you should not do)
  • Logging systems that truncate long header values

If your token works locally but fails in production, check whether the token that arrives at the verification service matches the token that was issued. Log the token length on both sides.


4. Base64URL Decoding Problems

A quick note on terminology: JWT uses Base64URL, not standard Base64. If you want to understand exactly why, the Base64URL explainer goes deep. But here is the practical difference:

Standard Base64Base64URL (JWT)
Uses +Uses -
Uses /Uses _
Always has = paddingPadding is optional and usually omitted

If you try to decode JWT payloads with a standard Base64 decoder, you will get padding errors, invalid character errors, or garbled output. This mostly bites developers building their own JWT inspection tools rather than using established libraries, but it also comes up in CI/CD scripts and debugging utilities.

The Padding Problem

Base64URL encoding in JWTs strips the trailing = characters. Many standard Base64 decoders require padding to be present, or at least for the input length to be a multiple of 4.

Quick padding fix in JavaScript:

function base64urlDecode(str) {
  // Replace URL-safe chars and add padding
  str = str.replace(/-/g, '+').replace(/_/g, '/');
  while (str.length % 4) str += '=';
  return Buffer.from(str, 'base64').toString('utf8');
}

// Decode the payload from a JWT
const [, payloadB64] = token.split('.');
const payload = JSON.parse(base64urlDecode(payloadB64));

Node.js 16+ has native Base64URL support via Buffer.from(str, 'base64url'), which handles this automatically. Browser environments have atob() but it does not handle Base64URL directly -- you still need to convert the character set first.


5. Wrong Secret or Public Key in Distributed Systems

Microservices make this problem a hundred times worse.

Service A generates the token. It signs with secret-microservice-a. The token travels through a message queue, or gets stored in a database, or is returned to the client and re-sent to Service B. Service B tries to verify with secret-microservice-b. Invalid signature.

The solution is to decide who owns the secret:

  • Shared symmetric secret: Every service that verifies tokens gets the same JWT_SECRET. Simple, but if any service leaks the secret, all services are compromised.
  • Asymmetric key pair: The auth service holds the private key and signs tokens. Every other service holds the public key and verifies. If a public key leaks, it does not matter. This is the better pattern for most multi-service deployments.

RS256 Public Key Pitfalls

When switching from HS256 to RS256, developers often make these mistakes:

  1. Using the wrong key entirely (passing the private key to jwt.verify() instead of the public key)
  2. Missing newlines in the PEM string (covered above)
  3. Using SSH-format keys instead of PEM format
  4. Generating keys with insufficient length (RSA keys should be at least 2048 bits; 4096 is preferred)

Generate a proper RSA key pair for JWT:

# Private key
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048

# Public key extracted from private
openssl rsa -pubout -in private.pem -out public.pem

Then verify:

const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// Sign
const token = jwt.sign({ userId: 42 }, privateKey, { algorithm: 'RS256' });

// Verify
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

6. Decode vs. Verify -- The Mistake That Keeps Happening

Understanding the difference between decoding and verifying is one of the most important things to learn when working with JWTs. If you want a complete walkthrough, I have a dedicated post on JWT decode vs. verify. The short version:

// DECODE -- reads the payload, does NOT check signature
const payload = jwt.decode(token);
console.log(payload.userId); // 42

// VERIFY -- checks the signature, then returns the payload
const payload = jwt.verify(token, secret);
console.log(payload.userId); // 42 -- and we know it is authentic

jwt.decode() does zero validation. Zero. It blindly Base64URL-decodes the payload segment and hands it back. If an attacker crafts a token with {"role": "admin"} in the payload, decode() will happily return it.

I once audited a codebase where a developer had written:

// DO NOT DO THIS
const user = jwt.decode(req.cookies.auth_token);
req.user = user;

The app relied on the user's role claim to gate admin features. Anyone could set their browser cookie to a self-made token with "role": "admin" and gain full access. The fix was a one-line change:

const user = jwt.verify(req.cookies.auth_token, process.env.JWT_SECRET);
req.user = user;

Always verify. Always. Even in middleware that "just" passes the token through to another service. The call to decode() should only appear in debugging tools and token inspection utilities.


7. Frontend Storage Issues

Browsers corrupt JWTs in subtle ways.

Extra Quotes from JSON Serialization

A developer stores the token in localStorage:

localStorage.setItem('token', JSON.stringify(token));

Then retrieves it:

const token = JSON.parse(localStorage.getItem('token'));

This works and the token is correct. But then someone refactors:

// Token was already stringified; this double-wraps it
const token = localStorage.getItem('token');

Now the token value is "eyJhbGciOi..." -- the double quotes are part of the string. When this gets sent in the Authorization header, it becomes:

Authorization: Bearer "eyJhbGciOi..."

The quotes break the Base64URL parsing, and you get a malformed token error.

Whitespace and Newlines

Tokens copied from terminal output, log files, or developer tools often pick up trailing newlines or leading spaces:

 eyJhbGciOi...  \n

This is invisible in most log viewers. If a developer copies a token from the terminal and pastes it into Postman, the whitespace comes along. Most JWT libraries will reject it because the first character is not a valid Base64URL character.

The fix: always .trim() tokens after extraction, and validate the structure before verification:

function sanitizeToken(raw) {
  if (!raw) return null;
  const cleaned = raw.trim();
  // Quick structure check: exactly 2 dots, no spaces
  if (cleaned.split('.').length !== 3 || /\s/.test(cleaned)) {
    return null;
  }
  return cleaned;
}

8. Algorithm Confusion Attacks and Misconfiguration

Algorithm confusion is both a security vulnerability and a common configuration mistake.

The Security Angle

Some JWT libraries have historically allowed tokens signed with alg: "none" or let the token's header dictate the algorithm without server-side validation. This means an attacker could change the header to "alg": "HS256" and sign the token with the server's own public key (treating it as an HMAC secret, since the public key is usually known).

Modern libraries have mostly fixed this by requiring explicit algorithm declaration during verification. But the fix only works if you use it:

// SAFE -- explicitly restrict algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });

// UNSAFE -- library default, may accept whatever the header says
jwt.verify(token, secret);

Always, always pass the algorithms option. Specify exactly what you accept. Usually that is one algorithm -- the same one you used for signing.

The Configuration Angle

The more mundane version of this bug: you deploy a new auth service that uses RS256, but your existing API services still verify with HS256 and a shared secret. The token header says RS256, the verifier expects HS256, and every request gets a 401.

The fix is to either migrate all services simultaneously (hard) or support both algorithms during a transition period:

function verifyToken(token) {
  const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
  if (header.alg === 'RS256') {
    return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
  }
  if (header.alg === 'HS256') {
    return jwt.verify(token, secret, { algorithms: ['HS256'] });
  }
  throw new Error(`Unsupported algorithm: ${header.alg}`);
}

9. The Nested Token Problem

This one deserves its own section because it is so common in OAuth and API gateway setups, and the error messages are spectacularly unhelpful.

Consider this flow:

  1. User authenticates via an OAuth provider (Google, Auth0, Okta)
  2. The provider returns an ID token (which is itself a JWT)
  3. Your backend issues its own JWT (an access token) for internal API calls
  4. The frontend stores the access token and uses it for API requests
  5. Everything works

Weeks later, a developer is debugging and opens Chrome DevTools. They see the token, paste it into the JWT Decoder, and see sub: "google-oauth2|123456789". They assume something is wrong with the identity mapping and start digging into the user database.

But the token they decoded is the wrong token. It is the Google ID token, not your application's access token. Both are JWTs. Both look identical in DevTools. The Google token decodes fine in any JWT decoder, but it will never pass your backend's verification because it was signed by Google's private key, not your secret.

This happens constantly. The fix is to be deliberate about which token is which: log the issuer (iss) claim, give your internal tokens a recognizable issuer like iss: "https://api.yourdomain.com", and never pass an external token to your own verification endpoint.


Real Debugging Workflow

When a JWT fails and you do not know why, here is the sequence that finds the problem fastest. I use this exact workflow on production incidents.

Step 1: Decode the Token First

Before checking any code, decode the token. Use the JWT Decoder or a one-liner:

# Extract and decode the payload (no verification)
echo "eyJhbGciOi..." | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .

Look at:

  • exp: Is it in the future? Convert it if you are not sure: date -d @1748000900
  • iat: Does the issued-at time make sense?
  • iss: Is this the issuer you expected?
  • alg (in the header): Is this the algorithm your verifier expects?

Step 2: Check the Token Structure

Count the dots. A valid JWT has exactly two of them. Run this sanity check:

# Must return 3
echo "eyJhbGciOi..." | tr '.' '\n' | wc -l

Also check for invisible characters:

# Should return empty output -- if you see hex codes, there is hidden whitespace
echo "eyJhbGciOi..." | cat -A

Step 3: Verify the Auth Header Format

In your middleware, log exactly what you are extracting:

const raw = req.headers.authorization;
console.log('Raw auth header:', JSON.stringify(raw));
console.log('Token length:', raw?.split(' ')[1]?.length);

The JSON.stringify wrapper reveals hidden characters (quotes, newlines) that console.log alone hides.

Step 4: Compare Secrets/Keys Across Environments

If the token was generated in one environment and verified in another, print the first and last few characters of the secret on both sides (do not log the full secret):

const secret = process.env.JWT_SECRET;
console.log('Secret prefix:', secret?.substring(0, 4));
console.log('Secret length:', secret?.length);

A length mismatch usually means the wrong environment variable is being loaded. A prefix mismatch means the value itself is wrong.

Step 5: Check the Algorithm End-to-End

Print the algorithm at signing time and at verification time. If they do not match, you found the bug:

// Signing
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
console.log('Signed with: HS256');

// Verifying
const header = jwt.decode(token, { complete: true }).header;
console.log('Token header alg:', header.alg); // should match

Step 6: Isolate the Token Transport

Strip everything else away. Sign a fresh token in a test script with the exact same secret and algorithm. Verify it in the same script. If that works, the token itself is fine and the problem is in the transport layer (proxy truncation, header rewriting, cookie corruption).


Best Practices for Reliable JWT Authentication

These are the rules that prevent most JWT incidents before they happen.

Always verify server-side. Client-side decoding is fine for UI purposes (showing the username, checking if the token "looks" valid), but never trust it for authorization decisions. The server must verify the signature on every protected request. If you need to understand the security implications of relying solely on decoding, remember that Base64 is not encryption -- anyone can read and forge the payload.

Keep access tokens short-lived. 5 to 15 minutes is the sweet spot. If a token leaks, the window of abuse is tiny. Pair short-lived access tokens with refresh tokens that can be revoked server-side.

Explicitly specify algorithms everywhere. Every call to jwt.verify() should include algorithms: ['HS256'] (or whatever you use). Never accept the library default. This prevents both security vulnerabilities and configuration drift.

Store secrets outside the codebase. Environment variables, secrets managers (AWS Secrets Manager, HashiCorp Vault, Doppler), Kubernetes secrets -- anything except hardcoded strings in source files. Rotate secrets periodically if your architecture supports it.

Never put sensitive data in the payload. JWT payloads are not encrypted. They are Base64URL-encoded. Anyone who possesses the token can decode and read the payload. This is one of the most persistent developer misconceptions about JWT. If you need to transport sensitive data, encrypt it before putting it in the payload, or do not put it in a JWT at all.

Log verification failures with context. The default invalid signature error tells you nothing. Wrap your verify calls:

function verifyAndLog(token, secret, context = {}) {
  try {
    return jwt.verify(token, secret, { algorithms: ['HS256'] });
  } catch (err) {
    const header = jwt.decode(token, { complete: true })?.header || {};
    console.error('JWT verification failed', {
      error: err.name,
      message: err.message,
      tokenPreview: token?.substring(0, 20) + '...',
      tokenLength: token?.length,
      header,
      ...context
    });
    throw err;
  }
}

This gives you the token header (including the algorithm it claims), the token length (to spot truncation), and whatever context you pass in (route, user agent, timestamp). When a 401 spike hits your production dashboard, these logs will tell you the root cause without needing to reproduce the issue.


FAQ

Why does my JWT say "invalid signature"?

Usually because the secret or public key used to verify the token does not match the one used to sign it. Less commonly, because the algorithm in the verification call does not match the algorithm in the token header, or because the payload was modified after signing.

Why does JWT verification fail after deployment but work locally?

Environment variables are the culprit about 90 percent of the time. Check that the same JWT_SECRET (or public key, or key file path) is available in production. Kubernetes secrets often strip newlines from PEM keys. CI/CD variables sometimes have different values per branch. Docker builds might bake in stage-specific secrets that do not exist in production.

Why can I decode a JWT but verification fails?

Because decoding and verification are completely different operations. Decoding simply Base64URL-decodes the payload. It does not check anything. Verification cryptographically validates the signature against the secret or public key. If you want a deeper explanation of why this distinction matters for security, read JWT Decode vs. Verify.

Why does my JWT say "malformed"?

The token does not have exactly three dot-separated segments. This usually happens because: the Authorization header contains "Bearer Bearer token" (doubled prefix), the token was truncated during copy-paste or in transit, or there is invisible whitespace somewhere in the string.

What does a valid JWT look like?

Three Base64URL strings separated by dots. Example structure:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgF0m3GMnF3H4Bh54K58

Each segment is Base64URL-encoded JSON. The first segment is the header, the second is the payload, the third is the signature.

Why does JWT use Base64URL instead of regular Base64?

To avoid characters that have special meaning in URLs and HTTP headers. The + and / characters in standard Base64 get URL-encoded (becoming %2B and %2F), which adds overhead and complexity. The = padding character also causes issues in some contexts. Base64URL replaces + with -, / with _, and strips padding. Every character in a Base64URL string is URL-safe.

Is JWT encrypted?

No, not by default. JWT is signed, not encrypted. Anyone can read the payload. If you need confidentiality, you want a JWE (JSON Web Encryption), which wraps a JWT in an encrypted envelope, or you encrypt the sensitive data yourself before placing it in the payload. The signature only guarantees that the payload has not been modified.

How do I refresh an expired JWT?

You cannot refresh an expired access token -- it is expired, full stop. What you do is: send a separate refresh token (also a JWT but with a longer expiration) to a dedicated /auth/refresh endpoint. That endpoint validates the refresh token, issues a new access token, and (optionally) rotates the refresh token. The client then retries the original request with the new access token. This is the standard OAuth 2.0 refresh token flow.

Why do multiple JWTs look the same in DevTools?

Because they are all just long Base64URL strings starting with eyJ. An ID token from Google looks identical to an access token from your own API when viewed in the network tab. The only way to tell them apart is to decode them and check the iss (issuer) claim. Add logging to your middleware that includes the issuer so you can quickly identify when the wrong token is being sent.


Debug Faster with the Right Tools

Most JWT debugging comes down to staring at encoded strings and trying to figure out what is inside them. Decoding tokens manually is tedious. Opening a Node REPL, importing jsonwebtoken, writing a one-liner -- it all adds friction that slows down the debug-verify-fix cycle.

The JWT Decoder on this site decodes the header and payload instantly. Paste your token, see the claims, check the expiration, verify the algorithm. No setup, no terminal, no dependencies. It runs entirely in the browser, so your tokens never leave your machine.

When you are staring at a "JWT invalid" error at 11 PM, having a decoder that works in one click instead of five terminal commands saves real time. Bookmark it.