AI Tools Compared

JWT debugging is surprisingly painful. The error messages from JWT libraries are terse, the base64url encoding obscures the payload, and issues like algorithm confusion attacks or misconfigured JWKS URIs require deep framework knowledge. AI tools dramatically speed up diagnosis. This guide shows practical prompting patterns for common JWT failure modes.

Common JWT Failure Categories

  1. Signature verification failures — wrong secret, wrong algorithm, RS256/HS256 confusion
  2. Claim validation errors — expired exp, wrong iss, wrong aud, nbf in the future
  3. Malformed tokens — incorrect padding, encoding issues, truncated tokens
  4. Algorithm confusion attacks — RS256 public key used as HS256 secret
  5. JWKS endpoint issues — key rotation failures, kid mismatch, network errors

Debugging Workflow

Always start by decoding the token. Never paste real production tokens — sanitize first.

# decode_jwt.py — decode without verification for debugging
import base64
import json
import sys

def decode_jwt_parts(token: str) -> dict:
    """Decode JWT header and payload without signature verification."""
    parts = token.split('.')
    if len(parts) != 3:
        return {"error": f"Expected 3 parts, got {len(parts)}"}

    def decode_part(part: str) -> dict:
        # Fix base64url padding
        padding = 4 - len(part) % 4
        if padding != 4:
            part += '=' * padding
        decoded = base64.urlsafe_b64decode(part)
        return json.loads(decoded)

    return {
        "header": decode_part(parts[0]),
        "payload": decode_part(parts[1]),
        "signature_length": len(parts[2]),
    }

if __name__ == "__main__":
    token = sys.argv[1] if len(sys.argv) > 1 else input("Paste token: ").strip()
    result = decode_jwt_parts(token)
    print(json.dumps(result, indent=2, default=str))

Run this first, then paste the output (never the raw token) to an AI tool for analysis.

Prompting Claude for JWT Diagnosis

Example scenario: Token rejected with “invalid signature”

Decoded header:

{ "alg": "HS256", "typ": "JWT" }

Decoded payload:

{
  "sub": "user_123",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "exp": 1742000000,
  "iat": 1741996400
}

Prompt:

JWT is being rejected with "invalid signature" by my Node.js Express app using
the `jsonwebtoken` library. The token was issued by Auth0.

Header: {"alg": "HS256", "typ": "JWT"}
Payload: [decoded payload above]

My verification code:
  jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] })

The JWT_SECRET env var is set to our Auth0 Application Client Secret.
What are the likely causes?

Claude’s diagnosis:

Claude identifies this immediately as an algorithm mismatch issue: Auth0 issues RS256 tokens (asymmetric) by default for APIs, not HS256 (symmetric). The HS256 in the header likely means the token was generated with a different configuration or the client secret is being passed where a public key is required.

Claude’s suggested fix:

// Option 1: Fetch Auth0's JWKS and use RS256
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');

const client = jwksClient({
  jwksUri: `https://YOUR_AUTH0_DOMAIN/.well-known/jwks.json`,
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 600000, // 10 minutes
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

jwt.verify(token, getKey, { algorithms: ['RS256'], audience: 'api.example.com' }, (err, decoded) => {
  if (err) console.error('Verification failed:', err.message);
  else console.log('Decoded:', decoded);
});

// Option 2: If you actually want HS256, change Auth0 API settings
// Auth0 Dashboard → APIs → Your API → Settings → Token Signing Algorithm → HS256
// Then use the API identifier's client secret, NOT the application secret

Algorithm Confusion Attack Debugging

This is a real security issue. Claude handles it precisely:

Prompt: “I’m seeing tokens succeed verification that shouldn’t. My server uses RS256. Could an attacker be exploiting algorithm confusion?”

Claude’s explanation and mitigation:

// VULNERABLE: Not explicitly specifying algorithms
jwt.verify(token, publicKey); // Attacker can send HS256 token signed with publicKey as HMAC secret

// SECURE: Always specify allowed algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

// Also validate the alg header explicitly before verification
function verifyTokenSecurely(token: string, publicKey: string): JwtPayload {
  const header = JSON.parse(
    Buffer.from(token.split('.')[0], 'base64url').toString()
  );

  if (header.alg !== 'RS256') {
    throw new Error(`Rejected: unexpected algorithm ${header.alg}`);
  }

  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    issuer: process.env.JWT_ISSUER,
    audience: process.env.JWT_AUDIENCE,
  }) as JwtPayload;
}

JWKS Key Rotation Debugging

A common production issue: JWKS key rotation causes sudden 401s.

# jwks_debugger.py — check if kid matches any key in JWKS endpoint
import requests
import base64
import json
import sys

def debug_jwks_mismatch(token: str, jwks_uri: str) -> None:
    # Decode header
    header_b64 = token.split('.')[0]
    padding = 4 - len(header_b64) % 4
    if padding != 4:
        header_b64 += '=' * padding
    header = json.loads(base64.urlsafe_b64decode(header_b64))

    token_kid = header.get('kid')
    token_alg = header.get('alg')
    print(f"Token kid: {token_kid}, alg: {token_alg}")

    # Fetch JWKS
    resp = requests.get(jwks_uri, timeout=10)
    resp.raise_for_status()
    jwks = resp.json()

    available_kids = [key.get('kid') for key in jwks.get('keys', [])]
    print(f"Available kids in JWKS: {available_kids}")

    if token_kid in available_kids:
        print("OK: kid found in JWKS")
    else:
        print(f"MISMATCH: kid '{token_kid}' not found in JWKS endpoint")
        print("This means the token was signed with a key that has been rotated out.")
        print("Action: Re-issue the token or check if JWKS caching is stale.")

if __name__ == "__main__":
    debug_jwks_mismatch(sys.argv[1], sys.argv[2])

Paste this script + the output to Claude:

Running this script gave:
Token kid: abc123
Available kids in JWKS: [xyz789, def456]
MISMATCH: kid 'abc123' not found

This is a production API. Users are getting 401s after a deploy. What happened and how do I fix it?

Claude will identify this as a key rotation without cache invalidation and provide the mitigation: extend JWKS cache TTL but add an explicit re-fetch on kid mismatch, then force re-auth for affected sessions.

Debugging Clock Skew and Expiry Issues

Clock skew between services causes intermittent jwt expired and jwt not active errors that are hard to reproduce. Claude generates a diagnostic script:

# check_jwt_timing.py — analyze exp/iat/nbf claims against current time
import base64
import json
import sys
import time
from datetime import datetime, timezone

def analyze_jwt_timing(token: str) -> None:
    parts = token.split('.')
    padding = 4 - len(parts[1]) % 4
    if padding != 4:
        parts[1] += '=' * padding

    payload = json.loads(base64.urlsafe_b64decode(parts[1]))
    now = time.time()

    print(f"Current server time: {datetime.fromtimestamp(now, tz=timezone.utc).isoformat()}")
    print(f"Current unix ts:     {now:.0f}")
    print()

    for claim in ['iat', 'exp', 'nbf']:
        if claim in payload:
            ts = payload[claim]
            dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
            delta = ts - now
            status = ""
            if claim == 'exp':
                status = "EXPIRED" if delta < 0 else f"expires in {delta:.0f}s"
            elif claim == 'nbf':
                status = "NOT YET VALID" if delta > 0 else "valid"
            elif claim == 'iat':
                status = f"issued {abs(delta):.0f}s {'ago' if delta < 0 else 'in the future (clock skew!)'}"
            print(f"{claim}: {ts} ({dt}) — {status}")

if __name__ == "__main__":
    token = sys.argv[1] if len(sys.argv) > 1 else input("Paste token: ").strip()
    analyze_jwt_timing(token)

When iat is in the future by more than a second or two, that indicates the issuing server’s clock is ahead of the validating server. The fix is NTP sync, but the short-term workaround is adding a clockTolerance option:

// jsonwebtoken: allow up to 30 seconds of clock skew
jwt.verify(token, secret, {
  algorithms: ['RS256'],
  clockTolerance: 30,  // seconds
});

Claim Validation Errors Reference

Claude can generate a debugging checklist when you describe validation error patterns:

Error Common Cause AI-Generated Fix
jwt expired exp in past Check server clock sync (NTP); add clock skew tolerance
jwt not active nbf in future Clock skew between issuer and verifier
invalid audience aud mismatch Pass correct audience option to verify()
invalid issuer iss mismatch Check env var for issuer URL (trailing slash matters)
invalid signature Wrong secret/key Verify you’re using correct key type for alg
jwt malformed Bad base64 encoding Check for URL encoding of . or + in token

Building a JWT Debugging Toolkit

Create a reusable CLI tool that handles common JWT problems and surfaces them to Claude:

#!/usr/bin/env python3
# jwt_debugger.py — comprehensive JWT diagnostics

import base64
import json
import sys
import requests
from typing import Tuple, Dict, Any
import anthropic

class JWTDebugger:
    def __init__(self, token: str):
        self.token = token.strip()
        self.parts = token.split('.')

    def is_valid_format(self) -> bool:
        """Check if token has 3 parts."""
        return len(self.parts) == 3

    def decode_safely(self) -> Dict[str, Any]:
        """Decode all parts without verification."""
        if not self.is_valid_format():
            return {"error": f"Token has {len(self.parts)} parts, expected 3"}

        result = {}
        for name, part in [("header", 0), ("payload", 1)]:
            try:
                # Fix base64url padding
                padding = 4 - len(part) % 4
                if padding != 4:
                    part += '=' * padding
                decoded = base64.urlsafe_b64decode(part)
                result[name] = json.loads(decoded)
            except Exception as e:
                result[name] = {"decode_error": str(e)}

        result["signature_length"] = len(self.parts[2])
        result["signature_hex"] = self.parts[2][:32] + "..."
        return result

    def check_expiration(self) -> Dict[str, Any]:
        """Check if token is expired."""
        try:
            payload = json.loads(
                base64.urlsafe_b64decode(self.parts[1] + '==')
            )
            if 'exp' not in payload:
                return {"has_exp": False}

            from datetime import datetime
            exp_time = datetime.fromtimestamp(payload['exp'])
            now = datetime.now()
            return {
                "expires_at": exp_time.isoformat(),
                "is_expired": now > exp_time,
                "seconds_remaining": int((exp_time - now).total_seconds())
            }
        except Exception as e:
            return {"error": str(e)}

    def check_jwks(self, jwks_uri: str) -> Dict[str, Any]:
        """Verify token kid against JWKS endpoint."""
        try:
            header = json.loads(
                base64.urlsafe_b64decode(self.parts[0] + '==')
            )
            token_kid = header.get('kid')
            if not token_kid:
                return {"error": "Token has no kid in header"}

            resp = requests.get(jwks_uri, timeout=5)
            resp.raise_for_status()
            jwks = resp.json()

            available_kids = [k.get('kid') for k in jwks.get('keys', [])]
            return {
                "token_kid": token_kid,
                "available_kids": available_kids,
                "kid_found": token_kid in available_kids
            }
        except Exception as e:
            return {"error": str(e)}

    def create_analysis_prompt(self, decoded: Dict, expiration: Dict,
                              jwks_check: Dict = None) -> str:
        """Format all diagnostics into a Claude prompt."""
        analysis = f"""JWT Diagnostics Report
======================

Decoded Token:
{json.dumps(decoded, indent=2)}

Expiration Status:
{json.dumps(expiration, indent=2)}
"""
        if jwks_check and 'error' not in jwks_check:
            analysis += f"\nJWKS Key Status:\n{json.dumps(jwks_check, indent=2)}"

        return analysis + "\n\nWhat are the issues with this token and how do I fix them?"

    def diagnose(self, jwks_uri: str = None) -> str:
        """Run full diagnostics and get Claude's analysis."""
        if not self.is_valid_format():
            return f"Token format error: {len(self.parts)} parts instead of 3"

        decoded = self.decode_safely()
        expiration = self.check_expiration()
        jwks_check = self.check_jwks(jwks_uri) if jwks_uri else None

        prompt = self.create_analysis_prompt(decoded, expiration, jwks_check)

        client = anthropic.Anthropic()
        message = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}]
        )

        return message.content[0].text


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: jwt_debugger.py <token> [--jwks-uri <uri>]")
        sys.exit(1)

    token = sys.argv[1]
    jwks_uri = None

    if "--jwks-uri" in sys.argv:
        idx = sys.argv.index("--jwks-uri")
        jwks_uri = sys.argv[idx + 1]

    debugger = JWTDebugger(token)
    analysis = debugger.diagnose(jwks_uri)
    print(analysis)

Usage in production debugging:

# Diagnose a token from your app logs
./jwt_debugger.py "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Diagnose with JWKS validation
./jwt_debugger.py "eyJ..." --jwks-uri "https://auth.example.com/.well-known/jwks.json"

# Output example from Claude:
# Token is expired by 3 hours (exp: 2026-03-22T14:30:00)
# The token was issued for audience 'api.old.example.com' but you're verifying against 'api.example.com'
# Action: Re-authenticate user or update audience configuration

Common JWT Issues Reference Table

Symptom Root Cause Test Command Fix
401 “invalid signature” Wrong secret or key Check if alg is RS256 or HS256 Verify secret rotation, check JWKS endpoint
“token expired” exp claim in past jwt_debugger.py --check-exp Re-issue token, check server time sync
“invalid audience” aud mismatch Decode payload, check aud field Ensure verify() gets correct audience
“invalid issuer” iss mismatch Decode payload, check iss field Verify issuer URL matches exactly (trailing slash matters)
“jwt malformed” Base64 encoding issue Run jwt_debugger.py first Re-encode token, check for URL encoding artifacts
“kid not found” Key rotation issue jwt_debugger.py –jwks-uri JWKS cache too old, refresh or re-issue

Testing JWT Integration with AI

Prompt Claude to generate test cases:

Generate Jest/Mocha test cases for verifying JWTs. Include:
1. Valid token verification
2. Expired token rejection
3. Wrong audience rejection
4. Algorithm confusion attack prevention
5. Key rotation (kid mismatch)
6. Clock skew tolerance

Use Auth0 as the issuer for examples.

Claude generates the full test suite with mocks, edge cases, and security assertions.

JWT Tools Comparison

Tool Debugging Speed Accuracy Learning Curve
Claude (with diagnostics) Fast (30-60s) 95%+ Low (natural language)
jwt.io web tool Instant Manual inspection Low (visual)
Manual base64 decode Slow (2-5 min) 70% (easy to misread) High
Dedicated JWT debuggers Moderate (1-2 min) 80% (limited context) Moderate

Claude wins because it connects the dots between multiple issues (wrong audience + expired = authentication problem, not authorization).


Built by theluckystrike — More at zovo.one