The door that was never locked
Notes from the Refactor — what we find when founders hand us their working product.
Notes from the Refactor, #1
The first thing we do when a new codebase lands on our desk at IDER is open the authentication routes. Not because we expect to find something wrong. Because authentication is where wrong things do the most damage, and because it's the part of the system that most accurately reports the discipline of the people who built it.
The repo I want to tell you about was a working product. Real users. A university was using it to track attendance for thousands of students. The logic was clever — QR codes, numeric codes, geolocation, the whole suite of things a modern attendance app should do. The founders were proud of it, and they had reason to be. They had shipped. Most founders never do.
We opened the auth folder. We opened auth.route.js. We scrolled to the password reset endpoint.
It looked like this:
router.post('/reset-password', resetPasswordController)
That was the entire route definition. No middleware. No authenticate. No verifyResetToken. No check of any kind.
The controller behind it did what you'd expect a password reset controller to do. It took an email. It took a new password. It hashed the password. It updated the user. It returned success.
Here is the entire attack, written out as one sentence:
I send a POST request to
/api/auth/reset-passwordwith{ "email": "admin@university.edu", "newPassword": "whatever" }and I am now the administrator.
No token. No email verification. No "are you really this person" step. The endpoint called reset-password did, in fact, reset the password. It just forgot to ask who was doing the resetting.
How this happens
I want to spend a minute on how, because if you're a founder reading this, you probably have a version of this bug in your codebase right now and I want you to understand why it's there.
The team that built this product was not careless. They were working on a deadline, with real users breathing down their necks, using a combination of AI assistants and late nights to ship features. The password reset flow, as they designed it, had three parts:
- User enters email → backend sends a 6-digit code to that email
- User enters the code → backend verifies it
- User enters new password → backend updates it
Step 1 existed. Step 2 existed (/verify-email). Step 3 existed (/reset-password).
What did not exist was the linkage between them. Each endpoint was built as a standalone unit. Each one did its job correctly, in isolation. Nowhere in the code did step 3 require evidence that step 2 had happened. The verification code was generated, sent, and checked — and then the password reset accepted any email and any new password without asking whether anyone had ever verified anything.
This is the shape of the bug that AI-assisted development produces most often: each piece works, nothing connects, and the connecting tissue is where the security lives. You ask the assistant to build a "reset password endpoint" and it builds one. You ask it to build a "verify code endpoint" and it builds one. You never ask it to build the state machine that says the second thing must happen before the first.
The assistant doesn't know what your product is. It knows what the endpoint's name suggests it should do. And what an endpoint called "reset password" suggests is: reset the password. So it did.
Why nobody caught it
You might be thinking: fine, but surely somebody tested this? Surely somebody tried to log in with a new password and noticed that the flow was broken?
They did. The flow worked from the UI. When you clicked "forgot password" in the app, you got the email, you entered the code, and then you set the new password. The three screens called the three endpoints in order. It felt like a working flow because, from the user's perspective, it was.
What nobody tested was what happens when you skip the UI. When you open Postman and call the third endpoint directly. The UI was the only thing keeping the door closed. The backend was wide open and had always been wide open.
This is the single most important thing I can tell a founder about security: your UI is not your security boundary. Your UI is a suggestion about how your API should be used. Your API will be used however an attacker wants to use it. If the only reason a user can't do something is that the button isn't there, they can do it.
What else we found in the same auth file
Because one security bug is never alone. When a team ships a reset endpoint without auth, they ship other things too. In the same file, in the same auth flow, we found:
The admin account was initialized in source code with the password 1234. Literally 1234. If you had read the repository, you knew the password of the primary administrator account. The account name was also in source code. So was the email.
New student accounts were created with a default password of 123456789 — again, in source code, the same value for every student ever created. When teachers uploaded class rosters via Excel, every student in the import got the same password. Every student at the university shared a password until they changed it, and there was no mechanism forcing them to change it.
New teacher accounts had the same treatment. Same default password, same source-code origin, same lack of forced rotation.
The login response included the bcrypt hash of the user's password. When you logged in successfully, the API sent you back your own password hash, along with every other field on the user record. If you were a moderately curious attacker with a valid account of any kind, you could collect hashes and run them offline at your leisure.
The global error handler returned err.message directly to the client. When the email server failed — which it did, because the SMTP credentials were half-configured — the full SMTP error message, including the server address and the authentication failure reason, was sent to the user's browser. Every 500 error was a small leak of the server's internals.
Each of these, on its own, is a line on an audit report. Together, they sketch a portrait of how the code was built: fast, feature-by-feature, with no moment where anyone stopped to ask "what does this endpoint look like from the outside, to someone who means harm?"
What the fix looks like
I don't want this post to just be a list of bad things. If you're a founder with a similar product, here is what the fix actually looks like, in the order you should do it.
Step one: put authentication middleware on every endpoint by default, and whitelist the ones that don't need it. Most frameworks let you do this. Express doesn't, by default, which is part of why this bug is so common in Express apps. Add a middleware that runs on every route, checks for a valid token, and rejects the request if there isn't one. Then, explicitly, mark the endpoints that should accept unauthenticated traffic: login, signup, password reset initiation, health checks. Everything else is locked by default.
The mental model matters. If you write your routes as "locked by default, opened by exception," you will never ship an unauthenticated reset endpoint again. If you write your routes as "open by default, locked by middleware you remember to add," you will ship this bug again and again for the rest of your career.
Step two: for password reset specifically, require the verification token to complete the reset. The flow should be:
POST /auth/forgot-password { email }
→ generates a single-use token, stores it with an expiry (15 min)
→ sends token to email as a link or code
POST /auth/reset-password { token, newPassword }
→ verifies token exists, matches email, hasn't expired, hasn't been used
→ only then updates the password
→ marks token as used
Note what's happening here. The reset-password endpoint is still technically unauthenticated — the user isn't logged in when they hit it. But it requires proof of email control, which is the whole point. The token is the authentication, just not the kind that involves a user session.
Step three: remove hardcoded credentials from source code. All of them. The admin seed password, the default student password, the default teacher password. Move them to environment variables for local development, and for production, generate cryptographically random temporary passwords and send them to the user's email. Force a password change on first login.
Step four: never return password hashes. Prisma has select and omit for exactly this reason. Pick one and use it on every query that returns a user object to a client. Better: build a UserDTO function that takes a Prisma user and returns only the fields that should ever leave the server. Run every user response through it. If you have to think about whether to include the hash, you will eventually forget.
Step five: in the global error handler, log the full error server-side and return only a sanitized message to the client. Your users don't need to know that the SMTP server rejected the credentials. They need to know that something went wrong and to try again later. The detail goes in your logs, where you can see it and they can't.
The larger point
This is the first post in a series about what we find when founders hand us their working products. I want to say something that might land uncomfortably, and I want to say it now so the rest of the series doesn't feel like dunking.
Shipping a product with bugs like this does not mean you failed. Shipping a product at all means you did something most founders never manage. You found a real problem, you built a thing that solves it, and you got it in front of users who depend on it. That is the hardest part of building a company, and the part that vibe coding genuinely does help with.
The trouble is that the skills required to find a product and the skills required to own a product are not the same skills. Finding rewards speed, intuition, and willingness to cut corners. Owning rewards discipline, pattern-recognition, and willingness to slow down and audit. Most founders are excellent at the first set and terrible at the second, which is a reasonable distribution of talent because those sets are in tension.
You are not supposed to be good at both. You are supposed to know when you need the second one, and then bring it in.
If you shipped a product and it has users and you suspect there are things in the codebase you don't understand — you are exactly where you are supposed to be. The next move is not to feel bad. The next move is to open the auth file.
Next in the series: the Prisma update() that forgot to await, and why the bugs that don't crash are worse than the ones that do.

Ahmed essyad
the owner of this space
A nerd? Yeah, the typical kind—nah, not really.
View all articles by Ahmed essyad→Comments
If this resonated
I write essays like this monthly.