← Back to Blog Security & Debugging

JWT Debugging Guide: Diagnosing Invalid Signatures & Common Errors

JSON Web Tokens (JWT) are the backbone of modern API authentication, but when they fail, they fail silently or with cryptic, frustrating error messages. The most notorious is the Invalid Signature JWT error, followed closely by Token Expired JWT and Invalid Audience JWT. Debugging these issues is a common pain point for developers because the token itself is an opaque, encoded string. You cannot easily read it without tools.

When faced with a failing token, the instinct is often to copy it and paste it into the first online decoder you find. Stop. Do not do this. Pasting production JWTs into random websites is a severe security risk. If a third-party site logs your token, they gain immediate impersonation access to your production APIs. This is exactly why we built the ZeroData JWT Debugger. It decodes base64url payloads entirely offline, in your browser. Your token never touches a server.

In this comprehensive guide, we will dissect the top five JWT errors developers encounter. We will explain exactly why they happen and provide concrete, step-by-step diagnostic workflows to fix them safely.

🔐 Privacy-First JWT Debugging Tools

Never expose production tokens. Use our 100% client-side tools:

Error #1: Invalid Signature JWT

The Invalid Signature error is the most critical and common roadblock. It means the backend API successfully parsed the token but rejected it because the cryptographic signature attached to the token does not match the signature computed by the backend.

Why It Happens

  • Secret Mismatch: In HS256 (symmetric), the secret key used by the issuer is different from the secret key configured on the API.
  • Public/Private Key Confusion: In RS256 (asymmetric), the API might be incorrectly trying to verify the token using the private key instead of the public key.
  • Tampered Payload: A proxy, client, or attacker modified the payload claims (e.g., changing role: user to role: admin). Because the payload is part of the signature generation, any modification renders the signature invalid.
  • Wrong Algorithm: The token was signed with RS256, but the server is explicitly attempting to verify it using HS256.

Step-by-Step Diagnostic Workflow

  1. Check the Algorithm: Decode the token header (the first part before the first dot). Check the alg parameter. If it says RS256, your backend must verify using a public key. If it says HS256, your backend must verify using a shared secret.
  2. Verify the Secret/Key: If using HS256, double-check your environment variables. A common mistake is a trailing space or newline character in the JWT_SECRET env variable. If using RS256, ensure your backend is correctly fetching the JWKS (JSON Web Key Set) endpoint.
  3. Inspect Token Integrity: Ensure no middleware in your network stack is modifying the Authorization header. Some aggressive WAFs (Web Application Firewalls) or proxies might strip or alter headers.

How to fix: Configure your backend JWT middleware to explicitly fetch the correct keys and whitelist the expected algorithm. Here is a robust example for Node.js using RS256 and JWKS:

// Example of setting up express-jwt securely
const { expressjwt: jwt } = require("express-jwt");
const jwksRsa = require("jwks-rsa");

app.use(
  jwt({
    // Dynamically fetch the public signing key
    secret: jwksRsa.expressJwtSecret({
      cache: true,
      rateLimit: true,
      jwksRequestsPerMinute: 5,
      jwksUri: "https://YOUR_DOMAIN/.well-known/jwks.json",
    }),

    // ALWAYS specify the expected audience and issuer
    audience: "https://api.yourdomain.com",
    issuer: "https://YOUR_DOMAIN/",
    
    // Explicitly whitelist the algorithm. Prevent 'none' attack!
    algorithms: ["RS256"],
  })
);

For a deeper dive into how signatures work and how to protect against advanced attacks (like the algorithm confusion attack), read our JWT Security Complete Guide.

Error #2: Token Expired JWT (The 'exp' Claim)

A Token Expired JWT error is intentionally designed behavior. It means the token was valid, but its lifetime has elapsed. The API correctly rejected it. However, if this happens unexpectedly or immediately upon login, it indicates a configuration or architectural flaw.

Why It Happens

  • Natural Expiration: The user has been active longer than the token's lifespan (e.g., 15 minutes) and your frontend lacks a refresh token mechanism.
  • Clock Skew: The server that issued the token and the server validating the token have desynchronized system clocks. If the verifying server is 2 minutes ahead of the issuing server, a 1-minute token will expire before it even arrives.
  • Milliseconds vs Seconds: The JWT specification (RFC 7519) mandates that the exp and iat claims must be Unix timestamps in seconds. If you issue a token using JavaScript's Date.now() (which returns milliseconds), the token will be severely miscalculated.

Step-by-Step Diagnostic Workflow

  1. Inspect the Claims: Paste your token into a secure, offline tool like our JWT Expiry Checker. Look at the iat (Issued At) and exp (Expiration Time) values.
  2. Check NTP Sync: Ensure all servers in your fleet (auth servers and resource servers) are synchronized using NTP (Network Time Protocol).

How to fix: First, ensure your backend allows a slight "clock skew" or "leeway" to tolerate minor time drift between servers. Most robust libraries support this configuration:

// Configuring Clock Skew in popular libraries
// Node.js (jsonwebtoken)
const decoded = jwt.verify(token, publicKey, {
  clockTolerance: 30, // 30 seconds skew allowed
  audience: 'https://api.example.com',
  algorithms: ['RS256']
});

// Python (PyJWT)
import jwt
decoded = jwt.decode(
    token, 
    public_key, 
    algorithms=["RS256"], 
    audience="https://api.example.com",
    leeway=30 # 30 seconds skew allowed
)

Second, implement a robust Refresh Token pattern. When the frontend receives a 401 Unauthorized (Token Expired) response, it should automatically use an HTTP-only refresh cookie to request a new access token, without forcing the user to log in again.

Error #3: Invalid Audience JWT (The 'aud' Claim)

The Invalid Audience JWT error is heavily prevalent when integrating with identity providers like Auth0, Okta, or AWS Cognito. The aud claim specifies the intended recipient (the "audience") of the token.

Why It Happens

If your frontend requests a token from Auth0, it must specify the API identifier (e.g., https://api.my-app.com). The identity provider embeds this into the aud claim. When the backend receives the token, it checks: "Is this token meant for me?" If the backend expects https://api.my-app.com but the token says aud: "my-frontend-client-id", the backend throws an Invalid Audience error.

Step-by-Step Diagnostic Workflow

  1. Decode the Token: Use the ZeroData offline debugger to view the payload. Locate the aud field. It can be a single string or an array of strings.
  2. Compare with Backend Config: Check your backend code (like the Express middleware example above). The string defined in the backend must match the token's aud exactly.

How to fix: Modify your frontend's authentication request to include the correct audience. For example, in an Auth0 SPA configuration, ensure you pass the audience parameter during the login redirect.

Error #4: Issuer Mismatch (The 'iss' Claim)

Similar to the audience claim, the iss (Issuer) claim dictates exactly who generated the token. If an API expects tokens from a specific authority but receives one from elsewhere, it throws an Issuer Mismatch error.

Why It Happens

This almost exclusively happens in environments with multiple tenants or stages. A developer might log into the Staging environment (issuer: https://staging.auth0.com/), but accidentally send that token to the Local or Production backend, which expects https://prod.auth0.com/.

Another common culprit is the trailing slash. https://my-domain.com is NOT the same issuer as https://my-domain.com/.

How to fix

Decode your token, observe the exact string in the iss claim, and ensure your backend environment variables perfectly match that string, character for character, including any trailing slashes.

Error #5: Malformed Token & Decoding Base64URL

A Malformed Token error means the parser couldn't even reach the signature or claim validation phase. The token string itself is structurally invalid.

Why It Happens

  • Missing Dots: A valid JWT must have exactly two dots separating three base64url-encoded strings (header, payload, signature).
  • Truncation: HTTP header limits or poor string concatenation truncated the token.
  • Base64 vs Base64URL: The payload was encoded using standard Base64 (which includes +, /, and = padding) instead of Base64URL (which replaces those characters to ensure URL safety).

How to Decode Base64URL in Custom Code

When building custom middleware or debugging scripts, developers often struggle to decode the token correctly because standard Base64 decoders fail on Base64URL strings. You must convert Base64URL back to standard Base64, add padding, and then decode.

JavaScript (Browser or Node.js) implementation:

// Securely decode base64url in JavaScript (Browser or Node.js)
function decodeJwtPayload(token) {
  // 1. Split the token and get the payload (middle part)
  const base64Url = token.split('.')[1];
  
  if (!base64Url) {
    throw new Error('Invalid JWT format');
  }

  // 2. Convert base64url to standard base64
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  
  // 3. Pad with '=' to make length a multiple of 4
  const padLength = (4 - (base64.length % 4)) % 4;
  const paddedBase64 = base64 + '='.repeat(padLength);

  // 4. Decode base64 to string
  let jsonPayload;
  try {
    // In browser: atob(), in Node.js: Buffer.from(..., 'base64').toString()
    if (typeof atob !== 'undefined') {
        const decodedString = atob(paddedBase64);
        // Handle UTF-8 characters properly
        const utf8String = decodeURIComponent(
          decodedString.split('').map(function(c) {
              return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
          }).join('')
        );
        jsonPayload = utf8String;
    } else {
        jsonPayload = Buffer.from(paddedBase64, 'base64').toString('utf8');
    }
    return JSON.parse(jsonPayload);
  } catch (e) {
    throw new Error('Failed to parse JWT payload');
  }
}

// Usage:
const claims = decodeJwtPayload('eyJo...payload...sign');
console.log(claims.exp, claims.aud);

Python implementation:

# Python snippet to decode base64url JWT payload
import base64
import json

def decode_jwt_payload(token):
    try:
        # Split token to get the payload
        payload_part = token.split('.')[1]
        
        # Add padding if necessary
        # base64url decoder in Python needs proper padding
        padded_payload = payload_part + '=' * (-len(payload_part) % 4)
        
        # Decode base64url
        decoded_bytes = base64.urlsafe_b64decode(padded_payload)
        
        # Parse JSON
        return json.loads(decoded_bytes)
    except Exception as e:
        print(f"Failed to decode: {e}")
        return None

# Usage:
token = "eyJhb...payload...sign"
claims = decode_jwt_payload(token)
print(claims)

Frequently Asked Questions (FAQ)

What does Invalid Signature JWT mean?
An Invalid Signature JWT error occurs when the cryptographic signature of the token does not match the computed signature on the backend. This means either the secret/key is incorrect, the payload has been tampered with, or the wrong algorithm was used to verify it.
How do I fix a Token Expired JWT error?
A Token Expired JWT error means the current time is past the timestamp in the 'exp' claim. To fix this, your client application must detect the expiration and use a Refresh Token to obtain a new Access Token. Also, ensure your backend correctly handles small clock skews between servers.
Why do I get Invalid Audience JWT?
The Invalid Audience JWT error happens when the 'aud' (audience) claim in the token does not match the expected audience configured in your API. This often occurs when using identity providers like Auth0 or Cognito and requesting a token for the wrong resource server.
How do I safely decode base64url JWTs?
To decode base64url JWTs safely, split the token by dots and base64url-decode the middle part (payload). Always do this locally. Do not paste production tokens into random third-party sites. Tools like the ZeroData JWT Debugger perform this decoding entirely within your browser.
Can I debug JWTs without exposing the secret?
Yes. The payload of a JWT is not encrypted, only encoded. You can decode and inspect the claims (like exp, aud, iss) to debug configuration issues without ever needing the secret key or private key.
Is it safe to use online JWT debuggers?
It is highly unsafe to paste production JWTs into server-backed online debuggers, as they may log your tokens. Only use 100% client-side, browser-based tools (like ZeroData Tools) where the token never leaves your machine.
Why is my exp claim causing immediate expiry?
The JWT specification requires the 'exp' claim to be a Unix timestamp in seconds, not milliseconds. If you accidentally set it using JavaScript's Date.now() (which returns milliseconds), the token will be evaluated as expiring in the year 52,000+ but some strict libraries might fail parsing, or conversely, if you set it to a future millisecond date that translates to a past second date, it expires instantly.
What is the difference between aud and iss claims?
The 'iss' (issuer) claim identifies who created and signed the token (e.g., your Auth0 tenant). The 'aud' (audience) claim identifies the intended recipient of the token (e.g., your specific backend API). A token must have the correct issuer and audience to be accepted.
How do I verify RS256 signatures?
RS256 uses an asymmetric key pair. The token is signed by the identity provider using a Private Key, and your backend verifies the signature using a Public Key (often fetched dynamically from a JWKS endpoint).
Why does my JWT have no signature part?
If your JWT ends with a dot and has nothing after it, it was created using the 'none' algorithm. This is a severe security risk. You should configure your backend library to reject unsigned tokens and explicitly whitelist accepted algorithms like HS256 or RS256.
What causes a malformed JWT error?
A malformed JWT error occurs when the token string is not in the expected 'header.payload.signature' format. This usually happens if the token gets truncated in HTTP headers, has invisible whitespace, or was base64-encoded instead of base64url-encoded.
How to handle clock skew in JWT verification?
Distributed systems rarely have perfectly synchronized clocks. Most JWT libraries allow you to configure a 'clock skew' tolerance (e.g., 30-60 seconds). This prevents tokens from being rejected immediately due to slight time differences between the issuing and verifying servers.
Should I log JWTs when debugging?
Never log full JWTs in production server logs. They are bearer tokens, meaning anyone who accesses the logs can impersonate users. If you must log for debugging, log only the decoded payload claims (like user ID) and obscure the signature.