What are you building?
The bird club wants a members area. Anyone can read the home page, but only members see the shared sightings and add their own. That one sentence pulls in most of this group of lessons: a sign-up form that stores passwords safely, a login that hands out a session, a page that checks the session on every request, a form that validates and escapes what members type, and a way to log out.
The starting file already has the plumbing: the database, a sessions map, the form parsers, cookie reading, the static pages in public/, and three helpers you wrote in earlier lessons, escapeHtml(), hashPassword() and passwordMatches(). Your job is the five routes. Each one is short. The skill being practised is putting them together without leaving a gap.
POST /signup: validate, hash, insert, redirect to/login.POST /login: look the member up, check the password, create a session, set the cookie, redirect to/members.GET /members: members only; list sightings, escaped.POST /sightings: members only; validate, insert with placeholders, redirect back.POST /logout: delete the session, clear the cookie, redirect home.
How should sign-up store the password?
Read the email and password from req.body, trim and lowercase the email so [email protected] and [email protected] are the same member, and check the password length. Current guidance from NIST asks for at least 15 characters when a password is the only thing protecting an account, and says not to force mixtures of symbols and digits. Long and memorable beats short and complicated. Answer 400 with a short message and a link back when the form fails.
Then store hashPassword(password), never the password. The helper makes a fresh random salt for every member and stores salt:hash, so two members with the same password get different values. After saving, redirect with 303 to /login. Redirecting after a successful form post means a refresh of the next page doesn't send the form again.
The UNIQUE rule on email makes the second sign-up with the same address fail. Catch that error and answer 409. Telling people an address is taken does reveal that it belongs to a member; most small clubs accept that, and the rate limit lesson shows how to slow down anyone using it to collect addresses.
What does logging in actually create?
Look the member up by email with a placeholder, and check the password with passwordMatches(). Give the same answer, 401 with "Email or password is wrong", whether the email is unknown or the password is wrong, so the login form can't be used to test which addresses are members.
On success, create a session id with crypto.randomBytes(32).toString('base64url'). That is 256 bits of randomness, far too many to guess. Store it in the sessions map with the member's id, and send it to the browser as the sid cookie. Make a new id on every login, never reuse one the browser already had: that blocks session fixation, where an attacker plants a known id in someone's browser and waits for them to log in.
res.cookie('sid', sid, {
httpOnly: true, // page scripts can't read it
sameSite: 'lax', // not sent with forms posted from other sites
secure: process.env.NODE_ENV === 'production', // HTTPS only when live
maxAge: 7 * 24 * 60 * 60 * 1000, // a week
});How does one middleware protect several routes?
Write the check once, as requireLogin(req, res, next). It reads req.cookies.sid, looks it up in sessions, and redirects to /login with 303 if there is no match. When there is one, it stores the member's id on req.memberId and calls next(). Put it in front of GET /members and POST /sightings. The handlers after it can trust req.memberId, because nothing reaches them without passing the check.
The lookup is the whole point. A cookie arrives from the browser, and anyone can send a sid cookie with any value from a script or the request bar. Only an id that is in the server's own map means someone logged in. Pages that browsers open get a redirect; if the club later adds a JSON API, those routes should answer 401 instead, because a script can't follow a redirect to a login form.


What has to happen to a new sighting?
Treat the form like any other visitor input. Trim the bird and the place, then check both are present and not too long. The required and maxlength attributes in the form help honest members, and anyone can skip them by sending the request directly, so the server checks again. A sighting of " " should get 400 and save nothing.
Insert with placeholders, including req.memberId so the club knows who saw what. Take the member id from the session, never from a hidden form field, or a member could post sightings in someone else's name. Store the text exactly as typed and escape it when the members page is built. Every value from the database goes through escapeHtml(): the bird, the place and the member's email.
What does logging out need to do?
Two things. Delete the session from the server's map with sessions.delete(req.cookies.sid), and tell the browser to forget the cookie with res.clearCookie('sid'). Clearing only the cookie leaves the id valid on the server, so a copy of it, from a shared computer or a stolen cookie, would still work. Deleting the session is what really ends it.
Use POST for logout, from a small form with a button. A logout link sent as GET can be triggered by any page that includes <img src="/logout">, which logs members out without asking. That is a nuisance more than a danger, and the same habit protects actions that do matter.
How do you test the whole flow?
Use the Browser view like a member would: open /members and get sent to the login page, follow the link to join, sign up, log in, add a heron, log out. Then repeat it with the Try buttons and read the Requests view. Each redirect shows as a 303 followed by a GET, and the cookies column shows sid arriving at login and disappearing at logout. The Data view shows the members table with hashes, the new sighting, and the browser's cookie jar.
Then attack it. Add a sighting with <b> in the name. Log out and press the back button, then reload. Send GET /members with a made-up cookie. Try a short password and an empty bird. Check my work runs the same kind of sequences: several requests in a row with cookies carried between them, like one browser.
What is still missing before real members use it?
Sessions live in memory, so every restart logs everyone out; a real club would store them in the database with an expiry time and delete old ones. The login route needs the rate limit from the previous lesson. A password reset by email, HTTPS on the live site, and the secure cookie flag that comes with it are next. When an AI tool writes a members area, check these same points first: hashing with a salt, a random session id looked up on the server, httpOnly and sameSite on the cookie, server-side checks on every private route, placeholders, and escaping.
Field notes
- Store a hash of each password, never the password itself.
- Logging in creates a server-side session and sets a random,
httpOnlycookie. - One middleware can protect several routes by checking the session first.
- Logging out deletes the session on the server and clears the cookie in the browser.
Easy to get wrong
A cookie that says who the visitor is can be edited by the visitor. Change the email and you are someone else. A random id means nothing on its own; only the server's map turns it into a member.
Hidden fields are sent by the browser and can be changed like any other field. Anything that decides who someone is must come from the session on the server.
Clearing the cookie only asks this browser to forget the id. The session still works on the server, so any copy of the id keeps the member logged in.
Questions people ask
Why 303 and not 302 for these redirects?
Both work in browsers, which turn the follow-up into a GET. 303 See Other says that explicitly: the form was handled, now fetch this page. res.redirect('/login') sends 302 by default, and res.redirect(303, '/login') picks 303.
Should members have to confirm their email address?
For a real club, yes. Send a link with a random, single-use token and mark the account confirmed when it is opened. Without it, someone can sign up with an address that isn't theirs. The practice server can't send email, so this project leaves it out.
Is the practice server's scrypt the real thing?
No. Here scryptSync is a quick stand-in with the same inputs and outputs, so the page stays fast. On a real server, crypto.scryptSync from Node.js does the slow, memory-hard work that makes stolen hashes expensive to crack. The code you write is the same.
Can a member stay logged in on several devices?
Yes. Each login creates its own session id, so a phone and a laptop each have one. A "log out everywhere" button would delete every session that belongs to the member, which is easier when sessions are stored in the database with a member id column.