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:
- Header: Specifies the token type and signing algorithm
- Payload: Contains the claims (user ID, permissions, expiration, etc.)
- 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) andexp(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:
- Decode the header with JWT Decoder — confirm algorithm
- Verify the secret matches the signing environment
-
Check if key rotation happened (use
kidheader)
Scenario 2: “Token Expired” but Token Is Fresh
Causes:
- Clock skew between servers
-
expclaim 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.