The Login Round Trip
Logging in to Sprout feels like a single click, but it crosses all three layers: a form in the browser, a password check on the server, a user row in the database, and then a signed pass that rides along with every later request. Each piece sits where it does for a reason, and moving any of them is how login systems get broken.
The rule
Prove it once with a password; carry a signed pass after that.
app.post('/api/login', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, password_hash FROM users WHERE email = $1', [req.body.email]);
const user = rows[0];
const ok = user && await bcrypt.compare(req.body.password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Email or password is wrong' });
const token = jwt.sign({ sub: String(user.id) }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600000 });
res.json({ id: user.id });
});
function requireUser(req, res, next) {
try {
const payload = jwt.verify(req.cookies.token, process.env.JWT_SECRET);
req.user = { id: Number(payload.sub) };
next();
} catch {
res.status(401).json({ error: 'Please log in' });
}
}Proving it. The database never holds passwords, only a slow one-way hash of each (bcrypt or Argon2). The server hashes the attempt the same way and compares. Unknown email and wrong password get the same reply, so the login form cannot be used to discover who has an account.
The pass. The server signs a JSON Web Token whose payload says "this is user 5, valid for one hour". Anyone can read a JWT's payload, but only the holder of JWT_SECRET can produce a valid signature, so an edited token fails the check.
Carrying it. The token travels in a cookie with three flags. HttpOnly hides it from page JavaScript, so an injected script cannot steal it. Secure sends it only over HTTPS. SameSite=Lax keeps the browser from attaching it to form posts that other websites send to Sprout. The browser then attaches the cookie to every request on its own.
Checking it. requireUser is middleware (reading req.cookies needs the small cookie-parser package). A missing, edited or expired token makes jwt.verify throw, and the answer is 401. Otherwise req.user is set and the handler runs.
Notice what the client does with the token: nothing. It cannot even read it. To show "Hi, Asha" it calls GET /api/me, a protected route that returns the user's public details.
The shortcut
For each piece of login data, ask "Who needs to read this?" The password: only the server, once, to compare with a hash. The token: only the server, so an HttpOnly cookie. The user's display name: the client, so an ordinary JSON response.
The form
The browser sends
POST /api/loginwith her email and password, over HTTPS.
Drills
Answer fast — trust the rule.
What does Sprout keep in the users table for a password?
Why does Sprout put the token in an HttpOnly cookie?
Someone changes their token's payload from {"sub": "5"} to {"sub": "6"}. What happens on the next request?
A login attempt fails. Which reply is best?
Remember
- Prove it once with a password; carry a signed pass after that.
- Shortcut: "Who needs to read this?" The server alone reads passwords and tokens; the client asks
/api/me. - Trap: storing passwords instead of hashes, or tokens where page scripts can read them.
