DEV Community

ramsha
ramsha

Posted on

JWT Authentication — 7 Common Mistakes Developers Make (And How to Fix Them)

I've seen these mistakes in codebases over and over again. Don't be that developer.

Why JWT Gets Misused So Often

JWT (JSON Web Tokens) looks simple on the surface. You generate a token, send it to the client, verify it on the server. Easy, right?
Wrong.

Most developers copy a tutorial, get it "working," and ship it to production without realizing they've left massive security holes open. I've been there too.

Here are the 7 mistakes I see most often — and exactly how to fix them.

Mistake #1 — Storing JWT in localStorage

This is probably the most common mistake and one of the most dangerous.

// ❌ WRONG — don't do this
localStorage.setItem('token', jwt)
Enter fullscreen mode Exit fullscreen mode

Why it's dangerous:
localStorage is accessible by any JavaScript on the page. If your app has even one XSS vulnerability, an attacker can steal every user's token in seconds.

The fix:
Store your JWT in an httpOnly cookie instead. JavaScript can't touch it.

// ✅ CORRECT — set it server side
res.cookie('token', jwt, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 24 * 60 * 60 * 1000 // 1 day
})
Enter fullscreen mode Exit fullscreen mode

Mistake #2 — No Token Expiration

// ❌ WRONG — token lives forever
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET)
Enter fullscreen mode Exit fullscreen mode

A token with no expiry is a permanent key to your kingdom. If it gets stolen, the attacker has access forever.

The fix:
Always set an expiration. Short-lived tokens are safer.

// ✅ CORRECT
const token = jwt.sign(
  { userId: user._id },
  process.env.JWT_SECRET,
  { expiresIn: '15m' }
)
Enter fullscreen mode Exit fullscreen mode

For better UX, pair short-lived access tokens with refresh tokens.


Mistake #3 — Weak or Hardcoded Secret Keys

// ❌ WRONG
const token = jwt.sign(payload, 'secret123')
const token = jwt.sign(payload, 'myappjwtsecret')
Enter fullscreen mode Exit fullscreen mode

If your secret is weak or committed to GitHub, your entire auth system is broken.

The fix:
Use a long, random secret stored in environment variables only.

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode
// ✅ CORRECT
const token = jwt.sign(payload, process.env.JWT_SECRET)
Enter fullscreen mode Exit fullscreen mode

Never commit your .env file to GitHub. Ever.

Mistake #4 — Not Verifying the Token Properly

// ❌ WRONG — just decoding, not verifying
const decoded = jwt.decode(token)
Enter fullscreen mode Exit fullscreen mode

jwt.decode() just base64 decodes the token. It does NOT verify the signature. Anyone can forge a token and your app will accept it.

The fix:

// ✅ CORRECT
try {
  const decoded = jwt.verify(token, process.env.JWT_SECRET)
  req.user = decoded
  next()
} catch (error) {
  return res.status(401).json({ message: 'Invalid token' })
}

## Mistake #5  Putting Sensitive Data in the Payload

Enter fullscreen mode Exit fullscreen mode


javascript
// ❌ WRONG
const token = jwt.sign({
userId: user._id,
password: user.password,
creditCard: user.cardNumber
}, process.env.JWT_SECRET)


JWT payloads are base64 encoded, not encrypted. Anyone can decode and read them.

**The fix:**

Enter fullscreen mode Exit fullscreen mode


javascript
// ✅ CORRECT — only what you need
const token = jwt.sign({
userId: user._id,
role: user.role
}, process.env.JWT_SECRET, { expiresIn: '15m' })


---

## Mistake #6 — No Refresh Token Strategy

Short access tokens expire fast. Most developers just make them last 30 days instead. That's the wrong solution.

**The fix:**
- Access token: 15 minutes
- Refresh token: 7-30 days, stored in httpOnly cookie

Enter fullscreen mode Exit fullscreen mode


javascript
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET)
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
res.json({ accessToken: newAccessToken })
} catch {
res.status(401).json({ message: 'Please login again' })
}
})


---

## Mistake #7 — Not Handling Token Revocation

Once issued, JWT can't be invalidated before expiry. If a user logs out, their token still works.

**The fix:**
Maintain a token blacklist in Redis.

Enter fullscreen mode Exit fullscreen mode


javascript
// On logout
app.post('/logout', async (req, res) => {
const token = req.cookies.token
await redisClient.set(blacklist_${token}, '1', { EX: 900 })
res.clearCookie('token')
res.json({ message: 'Logged out' })
})

// In auth middleware
const isBlacklisted = await redisClient.get(blacklist_${token})
if (isBlacklisted) {
return res.status(401).json({ message: 'Token revoked' })
}




---

## Quick Reference — JWT Checklist

| Practice | Status |
|----------|--------|
| Store in httpOnly cookie | ✅ Do this |
| Set expiration time | ✅ Always |
| Use strong secret in .env | ✅ Required |
| Use jwt.verify() not jwt.decode() | ✅ Always |
| Keep payload minimal | ✅ Less is more |
| Implement refresh tokens | ✅ For better UX |
| Handle token revocation | ✅ For production |

---

## Final Thought

JWT done wrong is worse than no auth at all — it gives you a false sense of security.

The good news? All of these fixes are simple once you know about them. Implement them once properly and you never have to worry again.

If you want all of this already built and production-ready, I packaged it into a MERN boilerplate — proper httpOnly cookies, refresh tokens, protected routes, clean folder structure, everything.

**🔗 Free version:** github.com/komalkhann001-sketch/mern-boilerplate

**🚀 Full production-ready version:** payhip.com/b/ZfbnM
*(One-time purchase — less than the cost of one hour of your freelance rate)*

*Found this useful? Drop a reaction and share it with a developer who needs to see this 🚀*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)