security
March 27, 2026 · 8 min read · 0 views

JWT Security & Debugging: A Complete Guide for Developers

Master JSON Web Tokens: learn how JWTs work, identify security vulnerabilities, and use proper debugging techniques to prevent authentication breaches.

Understanding JSON Web Tokens (JWT)

JSON Web Tokens (JWT) have become the standard for stateless authentication and authorization across modern web applications. Whether you’re building APIs, microservices, or single-page applications, understanding how JWTs work and where security risks hide is critical to protecting your application and users.

In this guide, we’ll explore JWT fundamentals, examine real-world security pitfalls, and show you practical techniques to decode, validate, and debug tokens safely.

What Is a JWT?

A JWT is a digitally signed, URL-safe token that contains claims (statements about an entity). Unlike session-based authentication where the server maintains state, JWTs are stateless—the server verifies the token’s signature rather than looking up a session.

A JWT has three parts, separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Each part is Base64URL-encoded:

  1. Header: Specifies the token type and signing algorithm
  2. Payload: Contains the claims (user ID, permissions, expiration, etc.)
  3. Signature: Proves the token hasn’t been tampered with

You can decode the example above using the JWT Decoder to see the actual structure.

How JWT Signing & Verification Works

HMAC (Symmetric) Signing

With HMAC, both the server and client share a secret key. The server signs the token and the client verifies it with the same secret:

const jwt = require('jsonwebtoken');
const secret = 'your-secret-key-keep-this-safe';

// Signing
const token = jwt.sign(
  { userId: 123, email: '[email protected]' },
  secret,
  { algorithm: 'HS256', expiresIn: '1h' }
);

// Verifying
try {
  const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
  console.log(decoded); // { userId: 123, email: '...', iat, exp }
} catch (error) {
  console.error('Invalid token:', error.message);
}

RSA (Asymmetric) Signing

With RSA, the server has a private key for signing and clients have a public key for verification. This is more secure for distributed systems:

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

const privateKey = fs.readFileSync('./private.key', 'utf8');
const publicKey = fs.readFileSync('./public.key', 'utf8');

// Signing with private key
const token = jwt.sign(
  { userId: 123, role: 'admin' },
  privateKey,
  { algorithm: 'RS256', expiresIn: '1h' }
);

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

RSA is ideal for microservices where an auth server signs tokens and multiple services verify them independently.

Common JWT Security Pitfalls

1. Using “None” Algorithm

One of the most critical vulnerabilities is accepting the none algorithm, which disables signature verification entirely:

// Attacker creates this:
const malicious = jwt.sign(
  { userId: 999, role: 'admin' },
  '',
  { algorithm: 'none' }
);

// ⚠️ VULNERABLE: Server accepts it without proper validation
const decoded = jwt.verify(malicious, secret); // Accepts unsigned token!

Fix: Explicitly whitelist allowed algorithms:

// ✓ SAFE: Only allow RS256
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

2. Weak Secrets

HMAC signing with weak secrets is easily brutable:

// ⚠️ WEAK: Attacker cracks this in seconds
const secret = 'password123';

// ✓ STRONG: Use at least 32 random characters
const secret = crypto.randomBytes(32).toString('hex');
// Output: a3f9e2c4b1d7f8a2e9c3f5b8d1a4e7c9...

Use the Password Generator to create cryptographically secure secrets:

Length: 32+
Type: Random bytes (hex)
No patterns or dictionary words

3. Expired Tokens Not Verified

Forget to check the exp claim and your tokens are valid forever:

// ⚠️ VULNERABLE: No expiration check
const decoded = jwt.decode(token); // Doesn't verify!

// ✓ SAFE: Verify and check expiration
const decoded = jwt.verify(token, secret); // Throws if expired

The jwt.verify() method automatically checks expiration. jwt.decode() only parses the token without validation.

4. Trusting Client-Provided Tokens Without Validation

If you trust the payload without verifying the signature, attackers modify claims:

// ⚠️ VULNERABLE: Attacker modifies payload
const headerPayload = token.split('.')[0] + '.' + token.split('.')[1];
const maliciousPayload = Buffer.from(
  JSON.stringify({ userId: 999, role: 'admin' })
).toString('base64url');
const forgedToken = headerPayload + '.' + maliciousPayload + '.' + signature;

// Decode and use without verification — tokens accepted!

Always verify the signature server-side.

5. Storing Sensitive Data in JWT

JWTs are encoded, not encrypted. Anyone can decode the payload:

// ⚠️ RISKY: Sensitive data in plain view
const token = jwt.sign(
  { userId: 123, ssn: '123-45-6789', password: 'plaintext' },
  secret
);

// Attacker decodes with [JWT Decoder](/tools/jwt) — sees everything!

Fix: Only store non-sensitive identifiers:

// ✓ SAFE: Only user ID and role
const token = jwt.sign(
  { userId: 123, role: 'user', email: '[email protected]' },
  secret
);

6. Algorithm Confusion Attack

Server expects RSA but accepts HMAC if not strictly validated:

// Server code (vulnerable)
const publicKey = fs.readFileSync('./public.key'); // RSA public key

jwt.verify(token, publicKey); // Might accept HS256 tokens!

Fix: Always specify expected algorithms:

jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Step-by-Step JWT Debugging Guide

Step 1: Decode the Token

Use the JWT Decoder to quickly inspect a token without running code:

Input: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

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

Payload: {
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Step 2: Check Expiration

The exp claim is a Unix timestamp (seconds since epoch). Use the Epoch Converter:

const expirationTimestamp = 1735689600; // From payload
const expirationDate = new Date(expirationTimestamp * 1000);
console.log(expirationDate); // January 1, 2025

const isExpired = Date.now() > expirationTimestamp * 1000;

Step 3: Verify the Signature

Manually verify HMAC signatures:

const crypto = require('crypto');
const [headerB64, payloadB64, signatureB64] = token.split('.');

// Reconstruct what was signed
const signedData = `${headerB64}.${payloadB64}`;

// Compute expected signature
const secret = 'your-secret';
const expectedSignature = crypto
  .createHmac('sha256', secret)
  .update(signedData)
  .digest('base64url');

const isValid = signatureB64 === expectedSignature;
console.log('Signature valid:', isValid);

Step 4: Validate Claims

Check application-specific claims:

const decoded = jwt.verify(token, secret);

// Check required claims
if (!decoded.userId) {
  throw new Error('Missing userId claim');
}

if (!decoded.role) {
  throw new Error('Missing role claim');
}

// Check claim values
if (!['user', 'admin', 'moderator'].includes(decoded.role)) {
  throw new Error('Invalid role');
}

console.log('Token valid and claims authorized');

Step 5: Test with Different Secrets

If signature verification fails, test with alternative secrets:

const secrets = [
  'prod-secret',
  'staging-secret',
  'local-secret',
  'old-secret'
];

for (const secret of secrets) {
  try {
    const decoded = jwt.verify(token, secret);
    console.log(`Valid with secret: ${secret}`);
    break;
  } catch (error) {
    console.log(`Failed with secret: ${secret}`);
  }
}

Practical JWT Implementation Example

Here’s a production-ready authentication middleware:

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

const app = express();
const publicKey = fs.readFileSync('./public.key', 'utf8');

// Middleware: Verify JWT
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],
      issuer: 'your-auth-server',
      audience: 'your-api'
    });

    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    if (error.name === 'JsonWebTokenError') {
      return res.status(403).json({ error: 'Invalid token' });
    }
    res.status(500).json({ error: 'Token verification failed' });
  }
};

// Protected route
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ user: req.user });
});

app.listen(3000);

Best Practices Checklist

  • Use RS256 or stronger for distributed systems; HS256 only for internal services
  • Set reasonable expiration (15 minutes for access tokens, days for refresh tokens)
  • Include iat (issued at) and exp (expiration) claims always
  • Whitelist algorithms explicitly—never accept none
  • Use HTTPS only — JWTs in Authorization headers need transport security
  • Rotate keys regularly and version them (use kid — Key ID)
  • Don’t store secrets in code — use environment variables or vaults
  • Verify on every request — don’t trust decoded tokens
  • Log authentication failures for security audits
  • Implement refresh tokens for long-lived sessions

Tools for JWT Testing

When building JWT authentication, use Kloubot’s API Request Builder to test token-protected endpoints:

GET /api/protected
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Body: (empty)

For quick testing, the Webhook Tester captures incoming requests with JWT headers for inspection.

Common JWT Debugging Scenarios

Scenario 1: “Invalid Signature” Error

Causes:

  • Wrong secret used for verification
  • Token was modified after signing
  • Signature algorithm mismatch (expecting RS256, got HS256)

Debug:

  1. Decode the header with JWT Decoder — confirm algorithm
  2. Verify the secret matches the signing environment
  3. Check if key rotation happened (use kid header)

Scenario 2: “Token Expired” but Token Is Fresh

Causes:

  • Clock skew between servers
  • exp claim set incorrectly
  • Server time is wrong

Debug:

const decoded = jwt.decode(token);
const now = Math.floor(Date.now() / 1000);
const expiresIn = decoded.exp - now;
console.log(`Token expires in ${expiresIn} seconds`);

// Allow clock skew
jwt.verify(token, secret, { clockTimestamp: 10 }); // 10s tolerance

Scenario 3: “Missing Required Claim”

Debug:

const decoded = jwt.decode(token);
console.log(JSON.stringify(decoded, null, 2));

// Check what claims are present
const required = ['userId', 'role', 'email'];
const missing = required.filter(claim => !decoded[claim]);
console.log('Missing claims:', missing);

Converting & Analyzing JWT Data

When troubleshooting, you may need to convert token data formats. Use the Base64 Encoder to manually decode JWT segments:

Input (Base64URL): eyJhbGciOiJIUzI1NiJ9
Output (UTF-8): {"alg":"HS256"}

For complex payloads, format them with JSON Formatter:

{
  "userId": 123,
  "email": "[email protected]",
  "roles": ["user", "moderator"],
  "iat": 1735689022,
  "exp": 1735692622
}

Why JWT Security Matters

JWT vulnerabilities can lead to:

  • Unauthorized access — attackers forge admin tokens
  • Session hijacking — stolen tokens grant full account access
  • Data exposure — sensitive claims visible to anyone
  • Privilege escalation — changing role claims to admin
  • Compliance violations — GDPR, HIPAA, PCI-DSS requirements

Proper JWT handling is non-negotiable for production applications handling user data.

Conclusion

JWTs are powerful for modern authentication, but they require careful implementation. Master the three-part structure, understand signing algorithms, validate rigorously, and debug systematically.

Use the JWT Decoder for quick inspection, the Epoch Converter for timestamp verification, and the API Request Builder for endpoint testing. With these tools and practices, you’ll build secure token-based auth systems your users can trust.

This post was generated with AI assistance and reviewed for accuracy.