Three Production Bugs That Taught Me More About Security Than Any Tutorial

Ahnaf AbidAbid
Aug 16, 2026
5 min read
Three Production Bugs That Taught Me More About Security Than Any Tutorial

Tutorials teach you syntax. Real production work teaches you what happens when things go wrong.

As a junior developer, most of what I knew about authentication came from courses and side projects. In those, everything is clean and nothing really breaks. It was only after I started fixing bugs on real production systems that I understood why "best practices" exist in the first place. Here are three bugs that taught me this the hard way.

Bug #1: The Tokens That Should Not Have Been There

It started with a login error. Users saw the message "Invalid user." Strangely, the same error showed up when they tried to create a post too. Two different features, one shared problem. That usually means the real issue is somewhere deeper, in a part of the system both features depend on.

I traced it back to the authentication flow. I found the problem: the frontend was storing both the access token and the refresh token in localStorage.

At first, this looks harmless. Many tutorials do it this way too. But localStorage has one big weakness: any JavaScript running on your page can read it. That is fine if the only script running is your own code. But it is a big problem if an attacker manages to inject even a few lines of bad script through an XSS attack. Once that happens, they can read your tokens too. No password needed.

The fix: move authentication to HTTP-only cookies.

res.cookie('accessToken', token, {
  httpOnly: true,   // JavaScript cannot read this — stops token theft through XSS
  secure: true,     // cookie only travels over HTTPS
  sameSite: 'strict' // helps stop CSRF attacks
});

With httpOnly: true, no script on the page can read the cookie, good or bad. After we removed the old localStorage code and used this pattern instead, the login and post-creation errors went away. The errors were never really about "invalid users." They were caused by tokens getting lost inside a broken login flow.

A second project had almost the same problem a few months later. The backend sent a raw token to the frontend, and the frontend stored it in localStorage again, with no cookie-based login in place. Same root cause, same fix. After seeing this twice, it stopped feeling like one mistake and started feeling like something worth checking for every time.

Bug #2: The Requests That Would Not Stop

Not every bug shows an error message. This one was quiet. Everything looked like it was working fine.

We only found it because we had the Network tab open while testing. We saw the same API call firing again and again, far more times than it should have. A useEffect was missing a dependency, or had the wrong one, and this created a request loop by accident.

Nothing crashed. Nothing showed an error. If we had not been watching the network traffic, this could have gone straight to production, quietly sending extra requests every time that part of the app loaded.

This changed how I use the Network tab now. It is not just for checking if a request worked. It shows what your app is really doing, not just what you think it is doing. Repeated calls, strange data, slow responses, silent failures — all of it shows up there if you look.

Bug #3: The Response That Said Too Much

The third lesson also came from watching network traffic. This time, one endpoint was sending back much more data than the frontend needed. This included data meant only for premium users, but it was being sent to every user.

Nothing was technically broken. The app still worked fine. But the API was leaking information it should not have shared. This happened because the backend was sending the whole database object instead of only the fields the frontend actually used.

The fix: stop sending the full database object. Choose exactly what to send back.

function toPublicUserDTO(user) {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    // premium fields, internal flags, etc. — left out on purpose
  };
}

Using DTOs (data transfer objects) let us control exactly what left the server, instead of sending "whatever happens to be in the database record." Even as the database grew over time, the API kept sending only what it should.

The Real Lesson

None of these bugs happened because someone was careless. They happened because security was not part of the plan from the start. It was found later, by accident, while testing something else.

This is the lesson that stayed with me: security is not something to think about after the code is done. It is a question to ask before you write the first line. Where will the tokens live? What does each endpoint really need to send? What happens if a script you did not write ends up running on your page?

If you remember only a few things from this post:

  • Do not store auth tokens in localStorage. Use HTTP-only cookies instead.
  • Keep the Network tab open while testing. It shows what your code is really doing.
  • Never send the full database object to the client. Choose the fields yourself with a DTO.

I still do not know everything, and I probably never will. But every bug I find in production teaches me something a tutorial never could.

Tags:
Share this article

Recent Articles

View all articles