Without notes, state yesterday’s main idea and one unresolved question.
Passwords and authentication flow
Backend Architecture, Authentication, and Security
Objective
Understand login without inventing security mechanisms.
Authentication identifies the operator; authorization checks which controls that operator is permitted to activate.
- password hashing concept
- sessions or tokens
- credential handling
Why this matters
Today your API learns who is talking to it. That single capability is what turns a demo into an application — and it is also the one place where a beginner mistake causes real harm to real people, because users reuse passwords across sites.
So today has a hard rule: you do not invent security mechanisms. You use a purpose-built password hash from a maintained library, you use well-understood session handling, and you write down exactly what your design does and does not protect against. By the end you have registration and login working, with no password ever stored, returned, or logged in readable form.
Authentication is not authorization
Two words that sound alike and are different jobs.
Authentication answers who are you? It happens once, at login, and its output is an identity. Authorization answers are you allowed to do this? It happens on every protected request, and its output is yes or no. Today is authentication only; tomorrow is authorization.
The badge and the key switch
Authentication identifies the operator: the badge reader confirms this is Ana from maintenance. Authorization checks which controls that operator may activate: Ana's badge opens the panel but the high-voltage disconnect still needs a supervisor key. Confirming identity and granting permission are separate circuits, and a system that treats "badge accepted" as "everything unlocked" is one badge away from an accident.
Why passwords are never stored
Databases leak. Backups get copied to laptops, a query gets logged, a credential ends up in a Git
history. Design as if the users table will one day be read by someone who should not have it.
That rules out two things immediately. Plain text is obviously fatal. Encryption is only slightly better, because encryption is reversible by definition — whoever stole the table will usually find the key beside it.
What you store instead is a hash: a one-way function of the password. Same input gives the same output; the output cannot be turned back into the input. At login you hash what was typed and compare hashes. You never need the original.
But a plain hash is not enough, for two reasons.
Identical passwords produce identical hashes. Crack one, crack every account that used it. The
fix is a salt: a unique random value per user, mixed in before hashing. Two people with the
password hunter2 now have completely different stored values, so an attacker must attack each
account separately. The salt is not a secret — it is stored right alongside the hash, and it still
works, because its job is uniqueness, not concealment.
General-purpose hashes are fast. SHA-256 is designed to hash a gigabyte quickly, which means commodity hardware tests billions of password guesses per second against it. Speed is the feature, and here the feature is the vulnerability.
A password hashing function — bcrypt, scrypt, argon2 — is built to be deliberately slow and memory-hungry, with a tunable cost factor. At bcrypt cost 12, one hash takes roughly a quarter of a second on ordinary hardware. Nobody notices a 250 ms login. An attacker who could have tried ten billion guesses per second is now limited to a handful, and a brute-force attack that took minutes now takes centuries.
Never use MD5, SHA-1, or SHA-256 for passwords
They are correct hashes and completely wrong for this job — too fast, and with no salt built in.
If you ever read a tutorial that stores sha256(password), close it. Use bcrypt, scrypt, or
argon2, from a maintained library, with the library's own salt generation.
What bcrypt actually gives you
Install it:
npm install bcrypt
npm install --save-dev @types/bcrypt
(bcrypt compiles native code. If the install fails on your machine, bcryptjs is a pure
JavaScript alternative with the same function names.)
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash('correct horse battery staple', 12);
console.log(hash);
$2b$12$Nn7d0YtCk3O0nB1qUuJ8B.qBiPjQ0N7X0tqmBcQmsxHkS1D0fO6Cq
Read that string. $2b$ is the algorithm version, $12$ is the cost factor, and the rest is the
salt and the hash together in one field. bcrypt generated the salt for you, randomly, and
carried it along — which is why you store one column, not two, and why hashing the same password
twice gives two different strings.
Verification does not re-derive anything by hand:
await bcrypt.compare('correct horse battery staple', hash); // true
await bcrypt.compare('wrong password', hash); // false
compare reads the cost and salt out of the stored hash, hashes the candidate the same way, and
compares the results in constant time. Never write that comparison yourself.
Two minutes, before you build anything
In a scratch file, hash the same password twice and print both results. They differ — that is
the salt. Then compare the original password against each hash; both return true. Seeing
that with your own eyes is what makes the salt concept stick.
Sessions or tokens: an honest comparison
Once a password is verified, the browser needs to prove on the next request that it already logged in. Two mainstream approaches, and neither is universally correct.
Server-side sessions. On login you create a row — a random session ID, the user ID, an expiry — and send the client the ID in a cookie. Each request looks it up.
- Revocation is immediate and total: delete the row and that session is dead. Good for "log out all devices" and for reacting to a compromise.
- Costs a lookup per request, and needs shared storage if you run more than one server.
Signed tokens (JWT is the common form). On login you send the client a token containing claims like the user ID, signed with a server secret. Each request verifies the signature — no lookup.
- Scales without shared storage, and works well across separate services.
- You cannot revoke it before it expires, unless you keep a denylist — which puts the storage and the lookup right back. Short expiry plus refresh tokens is the usual mitigation, and it is more moving parts, not fewer.
For this project — one server, first-party clients, and a real need to kick a session out immediately — server-side sessions are the better fit, so that is what you build. Say that choice out loud as a trade-off, not as a rule.
Cloakroom ticket versus signed letter
A session ID is a cloakroom ticket: meaningless by itself, and the cloakroom can refuse it at any moment. A signed token is a letter of introduction with a seal: anyone can verify the seal without contacting you, which is exactly why you cannot take it back once it is out.
Walkthrough: registration and login
Add the columns first — a migration, in the Day 48 style:
-- migrations/003_users_auth.sql
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL;
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'technician';
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
Now the service, using yesterday's layering:
// src/services/auth.service.ts
import bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto';
import * as users from '../repositories/user.repository.js';
import * as sessions from '../repositories/session.repository.js';
import { AppError } from '../errors.js';
const COST = 12;
const SESSION_DAYS = 7;
export async function register(email: string, password: string) {
if (!email || password.length < 12) {
throw new AppError('invalid', 'email required, password must be 12+ characters');
}
if (await users.findByEmail(email)) {
throw new AppError('conflict', 'account already exists');
}
const passwordHash = await bcrypt.hash(password, COST);
const user = await users.insert(email, passwordHash);
return { id: user.id, email: user.email, role: user.role };
}
export async function login(email: string, password: string) {
const user = await users.findByEmail(email);
const ok = user ? await bcrypt.compare(password, user.password_hash) : false;
if (!user || !ok) {
throw new AppError('invalid', 'invalid email or password');
}
const id = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
await sessions.insert(id, user.id, expiresAt);
return { sessionId: id, expiresAt, user: { id: user.id, email: user.email } };
}
Three details are deliberate. The returned object is built by hand so password_hash cannot
escape — never return user. The failure message is identical whether the email is unknown or the
password is wrong, so the endpoint cannot be used to discover which addresses have accounts. And
the session ID comes from randomBytes, a cryptographically secure source — never Math.random().
The route sends the ID as a cookie:
router.post('/auth/login', async (req, res) => {
const result = await service.login(req.body.email, req.body.password);
res.cookie('sid', result.sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
expires: result.expiresAt,
});
res.json({ user: result.user });
});
httpOnly means page JavaScript cannot read the cookie; secure means HTTPS only. Day 53 covers
what each of those actually defends against.
curl -i -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple"}'
HTTP/1.1 200 OK
Set-Cookie: sid=Xq3n...; Path=/; Expires=...; HttpOnly; SameSite=Lax
{"user":{"id":1,"email":"[email protected]"}}
Never log a password, a hash, or a session ID
console.log(req.body) on a login route writes plaintext passwords into your terminal, your log
files, and any log service you later attach. This is one of the most common real-world leaks.
Log the email or the user ID; never the credential. Day 54 makes this systematic.
Checkpoint
Query SELECT email, password_hash FROM users;. Every hash starts $2b$12$, no two are alike,
and you cannot read a single password. That is the whole point of today.
Your turn
- On paper, draw registration and login as two flows. For each, label the input, what is written to the database, what is returned to the client, and what happens on failure.
- Write the migration adding
password_hash,role, and thesessionstable. Run it. - Install
bcryptand do the two-minute experiment above if you have not yet. - Build
registerandloginin a service, with repositories forusersandsessions. - Add
POST /auth/registerandPOST /auth/loginroutes that set the cookie. - Register a user with
curl, then read the row: confirm the hash starts$2b$12$. - Log in with the right password (expect
200), then a wrong one (expect the same generic error), then an unknown email (expect the identical body and status). - Grep your code for leaks:
grep -rn "password" src/ | grep -i "log\|console"must find nothing, and no response body may containpassword_hash.
Reviewer mode — after step 8, not before
"Review this authentication flow for plain-text secrets, weak errors, and accidental credential exposure." Paste your auth service and routes. Ask for specific findings with file and line and a concrete exploit path, not praise and not a rewrite. Reject vague advice like "add more security"; demand the failing case. Then verify each finding yourself — an AI will occasionally flag something correct as a bug, and accepting that blindly is its own failure.
Common pitfalls
- Returning the whole user row.
res.json(user)shipspassword_hashto the browser. Build the public object explicitly, every time. - Different errors for "no such user" and "wrong password". It is friendlier and it hands an attacker a list of valid accounts. One message for both.
- Hashing on the client. Then the hash is the password — whoever steals it can log in. Send the password over HTTPS and hash on the server.
- Trusting
Math.random()for session IDs. It is predictable and not built for secrets. Usecrypto.randomBytes. - Passwords longer than 72 bytes with bcrypt. bcrypt ignores everything past 72 bytes. Set a maximum length so nobody is surprised, or use argon2, which has no such limit.
Verify it yourself
Open today's reference, the OWASP Top 10, and find the entry covering identification and authentication failures.
- Does OWASP agree that plain or fast-hashed passwords belong on the failure list? Find the sentence and quote it in your notes.
- This lesson chose bcrypt at cost 12. Find OWASP's own guidance on password storage and note whether it recommends a different algorithm or a different cost. If it does, write down what you would change and why.
Recording where an official source is stricter than your lesson is the habit that keeps security work honest as advice ages.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Diagram registration and login. Implement password hashing with a maintained library, never plain-text storage.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A working registration/login flow with no password returned or logged.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review this authentication flow for plain-text secrets, weak errors, and accidental credential exposure.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.