PLAINFINCH learn by building

Project 1: secure the Riverside Bird Club login

You will close four holes in the bird club's sign-in: fast hashing, open guessing, reused passwords and planted sessions.

What are you securing in this project?

The Riverside Bird Club has about two hundred members. They sign in to log sightings and book places on guided walks. The sign-in works, and members have never complained. It also has four separate weaknesses, each covered by one of the lessons in this group, and the Attacker view will show all of them.

Your job is to fix each one in turn without breaking the site, then confirm with the same attacks. The files are the ones you know: server.js holds the join, sign-in, sightings and sign-out routes, site.conf holds the sign-in settings, and headers.txt holds the session cookie line. package.json already lists bcrypt and otplib, so nothing needs installing.

Work in a fixed order. Read the Report view, run one attack, read its reason, make the smallest change that addresses it, and run the attack again. Changing several things at once makes it hard to tell which change did what.

Where should you start?

Start with the Report view. It lists each defence as on or off, and at the start of this project slow password hashing, the sign-in rate limit, two-step sign-in, a new session on sign-in, and the cookie's HttpOnly and Secure flags are all off. The attack list beneath shows which of those gaps an attacker can use today.

Then run all four password attacks once before changing anything. Crack a stolen database, Guess common passwords, Reuse leaked passwords and Plant a session id should all get through. Write down, even roughly, what each reason says. Comparing those notes with the results at the end is the clearest way to see what your changes achieved.

If you are unsure what a reason means, the Attacker view expands the latest attack into steps, and the Network view shows the sign-in request and the Set-Cookie header that comes back.

FigureFive attacks on the bird club sign-in, each matched to the one change that blocks it and the file it lives in.Five attacks on the bird club sign-in, each matched to the one change that blocks it and the file it lives in.
Each attack in the project has one matching fix. Work down the list one row at a time and rerun that attack before moving on.

How do you fix the stored passwords?

The /join and /sign-in routes both use crypto.createHash('md5'). MD5 is even faster than SHA-256, so a stolen table falls quickly. Replace the crypto import with bcrypt, store new passwords with await bcrypt.hash(req.body.password, 12), and check sign-ins with await bcrypt.compare(req.body.password, member.password_hash).

Change both routes in the same edit. If /join uses bcrypt but /sign-in still hashes with MD5, nobody can sign in, and the task's check notices that the old hash is still in the file. On a real site with existing members, you would also need the upgrade plan from the hashing lesson, because the old MD5 hashes cannot be converted directly.

app.post('/join', async (req, res) => {
  const hash = await bcrypt.hash(req.body.password, 12);
  db.run('INSERT INTO members (email, password_hash) VALUES (?, ?)', [req.body.email, hash]);
  res.redirect('/sign-in');
});

How do you slow down guessing and stop reused passwords?

Both of these are settings in site.conf. Add login_attempts_per_minute = 5 to limit how quickly anyone can try passwords. Then change two_step = off to two_step = required and add two_step_method = app, so a correct password leads to a code screen.

The two settings cover different attacks. The rate limit stops a bot hammering one account with common passwords, but credential stuffing tries each account once, so it never trips the limit. Two-step sign-in stops the reused password, but on its own it leaves the password guessable, and a guessed password is still worth having elsewhere if the member reused it. You need both.

Real rate limits are more careful than one number. They count per account and per network address, slow down repeated failures rather than locking accounts outright, and avoid letting an attacker lock real members out on purpose. The lab treats any limit between 1 and 10 a minute as enough.

How do you protect the session after sign-in?

In /sign-in, the lines that set req.session.memberId and redirect run on the visitor's existing session. Wrap them in req.session.regenerate(() => { ... }); so the member gets a fresh id the moment their password is accepted. Leave /sign-out as it is: it already destroys the session and clears the cookie, which is exactly right.

Then fix the cookie line in headers.txt. It has SameSite=Lax already, which blocks forged requests from other sites. Add HttpOnly so page scripts cannot read it and Secure so it never travels over plain HTTP. The finished line reads Set-Cookie: sid=random-id-here; Path=/; HttpOnly; Secure; SameSite=Lax.

Without Secure, Listening on cafe wifi gets through even though HTTPS is on, because the browser would still send the cookie on any plain HTTP request. That makes it the fifth attack in this project, even though the task does not name it.

How do you know you are finished?

The scoreboard under the view should read every attack blocked or not applicable, and the Report view should show every defence on. Run the four password attacks one last time so the Attacker view records them as blocked after your changes. The task check also confirms that nothing broke: the Page view should show no warnings.

Then read the reasons again. Crack a stolen database now mentions bcrypt and cost 12. Reuse leaked passwords mentions the code from the member's app. Plant a session id says the planted id stops working. Each reason should match the change you made.

What does the lab not check here?

The lab reads settings and patterns. It does not run the code step, so it cannot tell whether the server really refuses /sightings to someone who passed the password but not the code. It does not know whether the rate limit counts per account, whether recovery codes are hashed, or whether a password change ends a member's other sessions.

It also cannot see the sign-up rules from the strong passwords lesson unless block_common_passwords = yes is in site.conf. Adding it does no harm; it makes the slow hashing more effective, because the weakest passwords never get stored. Before a real launch, test each of these by hand: sign in with a password only and try to reach a members' page, mistype a code ten times, and sign out in one browser after changing the password in another.

What do AI tools get wrong when asked to secure a login?

Ask an assistant to make this login secure and the answer usually fixes one or two of the four problems and announces the job done. Commonly it swaps MD5 for SHA-256 with a salt, adds a rate limiter, and leaves session renewal and two-step sign-in out entirely. It may also suggest password rules about symbols and forced changes every 90 days, which current guidance has moved away from in favour of length and checks against leaked passwords.

Treat the attack list as your checklist and the assistant's code as a draft. For each attack, ask what in the code stops it, and confirm it in the lab or with a real test. If the answer is a comment that says the code is secure, keep looking.

Field notes

  • Replace the MD5 hash with bcrypt.hash at a cost of 12.
  • Limit sign-in attempts and refuse common passwords to stop guessing bots.
  • Require a second step so a leaked password alone cannot open an account.
  • Regenerate the session at sign-in and send the cookie with HttpOnly, Secure and SameSite.

Easy to get wrong

The two routes must agree on how passwords are hashed, or nobody can sign in.

Credential stuffing tries each account once, so a per-minute limit never trips.

Small steps show which change fixed what, and make a mistake easy to undo.

Questions people ask

Can I use argon2 in place of bcrypt?

Yes. Use await argon2.hash(req.body.password) and await argon2.verify(member.password_hash, req.body.password), and add argon2 to package.json. The check accepts either.

Does it matter which two-step method I pick?

The lab blocks credential stuffing with any required method. For a real club, offer app codes or passkeys, and keep text messages as a fallback only.

Why is the cookie in headers.txt when the code uses express-session?

So you can see and edit the flags as one line. In a real app you set the same flags in the cookie option of express-session.

What should I do after this project?

Move on to the browser's defences: HTTPS, cookie flags and the same-origin rule. They explain in detail why the cookie flags you just added work.

Keep going