I used to think writing my own authentication layer was a badge of honor. If you understand JWTs and can hash a password, how hard can it be? I wired up a Node backend, issued tokens, and called it done. The code worked. Tests passed. Then I started reading about how real attacks actually happen, and the floor fell out. Every chapter on injection, enumeration, and side-channel leaks sent me back to my editor with a sinking feeling. My auth system did not just have gaps; it had four wide-open doors that I had installed myself. Here is what I found, and exactly what I changed.

SQL Injection via String Concatenation

The first bug was the oldest trick in the book. I was taking user input and dropping it straight into SQL strings. In my login route, I grabbed the email from the request body and concatenated it into a query like SELECT * FROM users WHERE email = '${email}'. It felt harmless because I controlled the frontend. That is a dangerous assumption. An attacker does not need your frontend. A single crafted POST body could turn that login check into a data breach or a wiped database. Send a payload like ' OR '1'='1 or worse, a stacked query that drops tables, and if the string is executed raw, your data is gone. I had given the database no way to distinguish between my code and the attacker’s data.

The fix was not more input validation or escaping strings by hand. The real fix was parameterized queries. I switched to node-postgres and started using placeholders like $1. The query becomes a template: SELECT * FROM users WHERE email = $1. The driver sends the SQL and the values through separate channels. The database treats the input strictly as data, no matter what characters it contains. This one change closes the entire class of injection attacks. It is simpler to read, easier to maintain, and it removes the burden of being a regex wizard every time you write a WHERE clause.

Email Enumeration Through Error Messages

My second mistake looked like good UX. When a user typed the wrong email, I returned User not found. When they got the email right but the password wrong, I returned Incorrect password. It felt helpful. It was also a reconnaissance tool for attackers. Enumeration scripts can hammer your login endpoint with thousands of email addresses. If the response body or status code changes depending on whether the account exists, the script can build a verified list of your users. That list becomes the foundation for credential stuffing, targeted phishing, and further brute-force attempts.

I had to accept that user-friendliness sometimes needs to lose to security. I changed every failed login path to return the exact same string: Invalid credentials. No hints. No branching logic in the error response. Whether the email is missing, the password is wrong, or the account is locked, the text stays identical. This also applies to registration and password-reset flows; do not reveal whether an address is already in your system. One generic message removes an information leak that attackers depend on.

Timing Attacks in Password Comparison

Bug three was invisible. I was