Accounts and Progress
Learning Website with Next.js
Chapter 10 · Accounts & Progress (Optional)
Everything so far is made in advance and is the same for everybody. Remembering who someone is and what they have finished needs a program that runs when asked, and a database. This chapter adds exactly that, as little of it as possible: four small routes, one SQLite file, and no change to the pages themselves. The interesting part is not the code but what is locked, how each lock was tested, and what it costs.
Secure cookie flag), Apache, a second server, other browsers, a screen reader.
Do You Need This at All?
| Option | Cost | Used |
|---|---|---|
| Keep progress in the browser | Nothing to run; but per browser, and lost with it | No |
| Our own small API and SQLite | A Node process; the security is our job | Yes |
| A library such as Auth.js | A large dependency, and still a database | No |
| A hosted login service | Another company holds the passwords | No |
The Accounts Package
The decisions live in a package of plain functions with no web framework (@lw/accounts), so they can be tested hard without a browser. The
database is SQLite through Node's own built-in node:sqlite: nothing to install. (In Node 24 it is still marked experimental and prints a
warning when it starts: its interface could change.)
- No sign-up. The owner makes accounts with a small tool, so there is no registration form to attack or fill with junk. The password is read from the environment, not typed on the command line, so it is not left in the shell history.
- A password is never stored, only a scrypt hash with its own random salt. One check takes about 29 ms here: scrypt is slow on purpose, and so is guessing.
- A login token is 32 random bytes, and the database holds only a hash of it, so a copied database cannot log in as anyone.
- “Wrong password” and “no such user” look the same: the same words, and about the same time (a name that does not exist is checked against a decoy hash). Otherwise the words or the delay would tell an attacker which names are real.
- Five wrong passwords lock a name for 15 minutes, even for the right password; names that do not exist count too. The cost: anyone who knows your user name can lock you out for 15 minutes.
- Sessions last 30 days and are checked against the server each time, so logging out really ends them.
- Progress is stored by page path, so a rebuild never loses it (renaming a page file would). Deleting a user deletes their sessions and progress.
Four Routes: the Only Dynamic Part
/api/login, /api/logout, /api/me and /api/progress are marked force-dynamic: they run when asked, never at build time, never
cached. Every POST goes through the same locks:
| Lock | What it stops |
|---|---|
The request must come from our own pages (the Origin header must equal our host) | Another site making a visitor's browser send a request with their cookie. Even another of our sites is refused: each site has its own login |
| The request must say it is JSON | A plain form posted from another site |
| Cookie: HttpOnly, SameSite=Lax, no Domain, 30 days, Secure in production | Script reading the cookie; another site sending it; it leaking to other subdomains; plain http in production |
| Body read as text and limited to 4 KB; types checked | Huge bodies; a user name that is a list rather than text |
| A progress path must be a real page of this site | Made-up paths, ../../etc/passwd, other sites' pages |
Answers about a person: no-store, depending on the cookie | A shared cache showing one person's answer to another |
35 Checks Over Real HTTP
A script made up passwords for the run (kept in memory, never printed), started against the running app, and checked, among others: wrong password and unknown name give the same
401 and the same words and no cookie; a login with no Origin, from another site, or from another of our sites gets 403; a form post gets 415; GET on login gets
405; after 5 wrong passwords the right one gets 429 with Retry-After: 900 while another name is unaffected; the cookie has the right flags and the token is not in the body;
marking a page twice is one; four kinds of bad path are 404; another user can neither see nor undo it; logging out clears the cookie and the old cookie stops working at once; and
a made-up cookie is just “nobody”. 35 passed. Taking the same-origin check out and rebuilding gave 31 passed, 4 failed.
The Pages Stay Static
A lesson page requested with a valid login cookie is, byte for byte, the page everyone gets: 106,396 bytes, no user name, no “Mark as finished”, still saying “Log in”,
cacheable, and setting no cookie. That matters: if a page could differ by user, a shared cache could show one person's page to another. Everything personal arrives afterwards from
/api/me, which is never cached. So the page is sent logged-out, and the browser fills in the rest:
| Route kind | What |
|---|---|
| ○ static | The front page, /login, /progress, /search, /sitemap.xml, /robots.txt, /search-index.json |
| ● static, from a list | Every page and folder page of the languages site: 605 HTML files in all |
| ƒ dynamic | /api/login, /api/logout, /api/me, /api/progress |
The content is still built in advance, but the site is no longer just files: it needs a Node process (next start) for four routes. Two ways to deploy it, for Chapter 12: the whole
app as a Node server (what was tested), or the pages as plain files with only the accounts as a tiny separate server behind Apache's ProxyPass for /api (possible, because the
accounts package has no web framework in it; not built).
In a Real Browser
A headless Chrome typed a made-up password into the real form. 16 checks passed:
- Logged out, the header says “Log in” and the link remembers the page to come back to; a wrong password shows “Wrong user name or password.” and stays.
- The right password returns to the lesson. The header then shows the user's name and “Log out”, 77 ms after the page loaded; “Mark as finished” appears.
- Clicking it says “You have finished this page.”; after a full reload it still does (kept on the server).
/progressshows “Hungarian Basic Conversation 3: 1 of 12” with a progress bar; “Mark as not finished” undoes it without a reload.- Logging out brings back “Log in” and removes the button; logged out,
/progressasks you to log in.
The 77 ms is the flash: a logged-in visitor briefly sees “Log in” and no button before the browser has asked who they are. A page that cannot be personalised in advance has to do this; logged-out visitors, who are most people, see nothing change.
?next=. If that could be anything, a link such as /login?next=//evil.example would send a freshly logged-in visitor to a
site that looks the same. Only addresses inside the site are followed (//evil.example, https://…, javascript:… and backslash tricks all give /); a unit
test lists them. My first attempt to plant this mistake did nothing: my edit did not match the code, so the code was unchanged and the test passed. I noticed because the count of
changed lines was 0. Done properly, the test failed, with the browser heading for //evil.example. Confirm that a planted mistake is really in the code before believing the test that passes.
Secure flag is added only by a production build and was not seen on a real response); a second server sharing the database (SQLite handles several processes on one machine, not a
network drive); other browsers; a screen reader; a password manager. For Apache (Chapter 12): the Origin check compares Origin with the Host header, so Apache must pass the
original Host on (ProxyPreserveHost On) or every login will be refused with 403. There is no password change or reset (no email is set up: the owner deletes and re-makes the user), and the
other sites have no accounts yet.
Hands-On Exercises
Write accounts as a small tested package: hashed passwords, session tokens stored only as hashes, an identical answer for wrong password and unknown name, a lockout, and progress that cannot leak between users. Prove each rule by changing the code and watching the tests fail.
📄 View solutionBuild the four API routes with an origin check, a JSON check, safe cookie flags and strict input checks, then test every lock over real HTTP and show that removing one makes the tests fail. Show that a page requested by a logged-in browser is still the same for everyone.
📄 View solutionAdd the login page, a header that shows who is logged in, a Mark as finished button and a progress page, all filled in by the browser after the static page loads. Close the open redirect, test the whole thing in a real browser, and list what is and is not static.
📄 View solutionChapter 10 Quick Reference
- Own small API + SQLite (
node:sqlite, built in, experimental in Node 24); no sign-up; accounts made by the owner withaccounts-admin.mjs(password from the environment) - Passwords: scrypt hash with a random salt (about 29 ms a check); session token: 32 random bytes, only its hash stored; sessions 30 days, checked on the server
- Wrong password and unknown name: same answer, same words, similar time; 5 wrong passwords lock a name for 15 minutes (the cost: anyone can lock you out)
- Every POST: same
Originas our host (403), JSON (415), body under 4 KB, types checked; cookie HttpOnly, SameSite=Lax, no Domain, Secure in production - Progress by page path, only for real pages of this site (404 otherwise); another user cannot see or undo it
- Pages stay static and identical for everyone; the browser asks
/api/me(no-store) afterwards: a logged-in visitor sees a flash of about 77 ms - Only
?next=addresses inside the site are followed (an open redirect otherwise) - A test that follows a constant cannot notice the constant changing: write the policy out in plain numbers; and check that a planted mistake is really in the code
- 35 HTTP checks (31 pass, 4 fail when the origin check is removed), 16 browser checks, 90 tests
- Needs a Node server for four routes; Apache must keep the Host header; not tested: HTTPS, Apache, other browsers; no password reset