Building a login route
Signup works. Users can create accounts. But right now, there is no way for an existing user to come back and prove they already have one. That is what login does, and it is the other half of what we need before we can start talking about sessions and cookies. This lesson we write the POST /login route, and along the way, we run into one of the most important security details in the whole course. It is a detail that looks like nothing. That is exactly what makes it so dangerous.
What login needs to do
Login is simpler than signup. We do not create anything new. We just verify what is already there. Three steps:
- Receive an email and password from the client.
- Look up the user by email.
- Compare the attempted password against the stored hash.
If everything checks out, the user is authenticated. But wait, then what? What do we send back? This is exactly the statelessness problem from Section 1. We have proved who they are, but HTTP does not remember. We will fully solve that in Section 3 with sessions. For now, our login route will just return a success message so we can see the basic mechanism working.
The login schema
First, add a schema for the login body. Open src/schemas.ts and add a new export:
// src/schemas.ts
import * as z from "zod/v4";
export const SignupBody = z.object({
email: z.email(),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export const LoginBody = z.object({
email: z.email(),
password: z.string().min(1, "Password is required"),
}); Notice something: LoginBody does not require the password to be 8 characters or longer. Why? Because the rules at login time are different from the rules at signup time. At signup, we are creating a brand new password, so we can enforce “it must be at least 8 chars.” At login, we are just checking whatever was stored. Imagine that six months from now you tighten the rule to require 10 characters. Users who created accounts back when 8 was allowed still need to log in. If login enforced the new rule, they would be locked out forever.
The login route’s job is to verify the password, not to police its strength. Let whatever the user typed through, and let the hash comparison decide whether it is right.
The login route
Now we add the route. Open src/routes/auth.ts and add the login route inside the group:
// src/routes/auth.ts
import bcrypt from "bcryptjs";
import { route, group } from "@hectoday/http";
import { users } from "../db.js";
import { SignupBody, LoginBody } from "../schemas.js";
export const authRoutes = group([
route.post("/signup", {
request: { body: SignupBody },
resolve: async (c) => {
// ... same as before
},
}),
route.post("/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password } = c.input.body;
// Look up the user
const user = users.get(email);
if (!user) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
// Compare the password against the stored hash
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
// Authentication succeeded
// TODO: create a session or token here (we'll do this in Section 3)
return Response.json({
message: "Login successful",
user: { id: user.id, email: user.email, role: user.role },
});
},
}),
]); Let’s walk through this. The validation check at the top is the same pattern we used in signup: if c.input.ok is false, return a 400 with the issues.
Then we pull out the email and password, look up the user with users.get(email), and check the password with bcrypt.compare() (exactly like we did in Section 2). If either step fails, we return 401 Unauthorized.
If everything works, we return a success message and the user’s public data. Notice we do not return the passwordHash, same as in signup. The hash never leaves the server.
The security detail you almost missed
OK now look back at those two error responses carefully. Really look at them:
if (!user) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
// ...
if (!valid) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
} They are literally the same error message. That is not a copy-paste accident. It is intentional. And understanding why is one of the most important things in this entire course.
Imagine for a second we had used different messages, like “User not found” for a missing email and “Wrong password” for a bad password. Seems helpful for debugging, right? It is also a security hole. Here is what an attacker would do:
They would send a login request with a random email and literally any password:
POST /login { email: "[email protected]", password: "x" } If they get back “User not found,” they know Alice does not have an account on your site. If they get back “Wrong password,” they know Alice does. Now they just run this attack against a list of email addresses and they have a confirmed list of every person registered on your service. They can then use that list to phishing-spam those people, match accounts across breaches, or whatever else.
This is called an enumeration attack. It is how attackers discover which emails are valid on a service without actually cracking anything.
The fix is almost comically simple: return the exact same error message regardless of which part failed. An attacker probing your endpoint sees one response no matter what. They learn nothing about which accounts exist. That is why the two branches both say “Invalid email or password.”
[!WARNING] Always return the same error message for “user not found” and “wrong password” on login. Different messages leak information about which accounts exist on your service.
This is the kind of detail that looks like nothing in code review. It is one identical string. But it is the difference between an attacker owning your user list and not.
About that 401 status
One quick tangent. We are using status 401 here, and 401 has a misleading name. It says “Unauthorized” but in modern terms it actually means “not authenticated,” not “not authorized.” The HTTP specification predates the clean distinction between those two words, so the naming is backward, but the code itself is correct:
- 401 means: I don’t know who you are. Provide valid credentials.
- 403 means: I know who you are, but you are not allowed to do this.
We will use 403 in Section 5 when we build actual authorization. For now, remember: failed login returns 401, full stop.
Try it out
Sign up a user first:
curl -X POST http://localhost:3000/signup \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' Then log in:
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' {
"message": "Login successful",
"user": { "id": "a1b2c3d4-...", "email": "[email protected]", "role": "user" }
} Try a wrong password:
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "wrongpassword"}' { "error": "Invalid email or password" } Try an email that does not exist at all:
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}' { "error": "Invalid email or password" } Exactly the same response. An attacker probing the endpoint learns nothing about which emails are registered.
The gap
Our login route works. The credentials get verified correctly. But something is missing, and it is the thing we warned about back in Section 1.
Once login succeeds, the server immediately forgets. The user’s next request, maybe a GET /dashboard a second later, will arrive with no evidence that login ever happened. HTTP is stateless, and we have done nothing yet to bridge that gap. The authentication check passed, but then the moment ended.
This is the whole reason Section 3 exists. We need a way for the client to carry proof of their logged-in status from one request to the next. In the next section, we solve exactly this problem, starting with a technology you have heard of a million times but probably never built from scratch: cookies.
Exercises
Exercise 1: Add a POST /login request to your testing workflow. Sign up a user, log in with the correct password, then log in with the wrong password. Observe the status codes and response bodies. Note how identical the error responses are.
Exercise 2: Intentionally change the login route to return different error messages for “user not found” and “wrong password.” Use curl to probe for existing accounts. Then change it back. Actually do this one, even if it feels pointless. Demonstrating the enumeration attack to yourself is the fastest way to make the security detail stick.
Exercise 3: What happens if you call POST /login without signing up first? What happens if you send a login request with an empty body? Try both and note the responses.
Why does the login route return the same error message for 'user not found' and 'wrong password'?
Why does LoginBody use min(1) for the password instead of min(8) like SignupBody?