150: Authentication and Polish
Outcomes
By the end of this study session, you can:
- distinguish authentication (AuthN), session management, and authorization (AuthZ);
- explain registration, login, logout, and protected-request lifecycles;
- hash passwords with Argon2id rather than storing plaintext or fast hashes;
- use a server-side session with a secure cookie; and
- enforce task ownership in database queries on every request.
The one-hour core is deliberately small. Production identity systems also need email verification, password recovery, MFA/passkeys, breach-password checks, rate limiting, monitoring, and operational secret rotation.
Retrieval warm-up (5 minutes)
- Why can CORS not decide whether a user may delete a task?
- Where did 105 keep
MONGODB_URI, and why? - What must the Fetch service do with a
401response?
Expected ideas: CORS controls browser response sharing, not permissions; secrets stay on the server; check response.ok, surface an appropriate state, and do not assume every fulfilled Fetch promise succeeded.
Terms and mental model
- Authentication (AuthN): Verifying who a user is before granting anything. — Source: OWASP Authentication Cheat Sheet
- Authorization (AuthZ): Deciding what the authenticated identity may do per resource. — Source: OWASP Authorization Cheat Sheet
- Credential: Proof presented for authentication (password, session ID, token). — Source: OWASP Authentication Cheat Sheet
- Password hash: One-way transformed secret stored instead of the password itself. — Source: OWASP Password Storage Cheat Sheet
- Salt: Unique random value mixed into each hash preventing rainbow-table reuse. — Source: OWASP Password Storage Cheat Sheet
- Session: Server-tracked authenticated state spanning multiple requests. — Source: OWASP Session Management Cheat Sheet
- Session ID: Random identifier linking a browser to its server-side session record. — Source: OWASP Session Management Cheat Sheet
- Cookie attributes: HttpOnly/Secure/SameSite flags scoping and protecting session cookies. — Source: OWASP Session Management — cookies
- CSRF: Attack forcing authenticated browsers to send unwanted state changes; mitigated with SameSite/tokens. — Source: OWASP CSRF Prevention Cheat Sheet
- Ownership check: Server verifying the resource belongs to the requesting user before acting. — Source: OWASP Authorization Cheat Sheet
The boundaries are essential. A successful login proves an identity and starts a session. It does not give that user every task. Each task request loads the session, authenticates the request from userId, then authorizes the object-level action. Hiding another user's Delete button is only presentation; an attacker can send HTTP directly.
Architecture
For this browser app, prefer a same-site deployment: https://tasks.example.com serves React and proxies /api to Express, or the frontend/API use trusted same-site hosts. Express stores session data in MongoDB; the browser stores only an opaque signed session ID in an HttpOnly; Secure; SameSite=Strict cookie. React calls /api/auth/me to learn the public user profile.
This avoids recommending a bearer token in localStorage or sessionStorage, where any JavaScript running after an XSS flaw can read and exfiltrate it. HttpOnly protects cookie confidentiality, but XSS could still issue requests as the user, so output handling and a Content Security Policy still matter.
Cookies create CSRF implications because the browser sends them automatically. SameSite=Strict is useful defense in depth, not a universal replacement for CSRF protection. The core additionally rejects untrusted Origin values and accepts JSON for state-changing API operations. A production design should use a maintained CSRF middleware/pattern, such as a session-bound synchronizer token, especially if cross-site flows or untrusted sibling subdomains are possible. Never use state-changing GET routes.
Core implementation (35 minutes)
Use the Node 24.19 LTS baseline, which includes the official asynchronous node:crypto Argon2 API. Install express-session and a compatible production session store such as connect-mongo; Express warns that the default MemoryStore leaks memory and does not scale.
1. Model users and owned tasks
// models.js
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
email: {
type: String, required: true, trim: true, lowercase: true, unique: true,
maxlength: 254, match: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
passwordAlgorithm: { type: String, required: true, select: false },
passwordVersion: { type: Number, required: true, select: false },
passwordParams: { type: Object, required: true, select: false },
passwordHash: { type: Buffer, required: true, select: false },
passwordSalt: { type: Buffer, required: true, select: false },
}, { timestamps: true });
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 120 },
completed: { type: Boolean, default: false },
priority: { type: Number, enum: [1, 2, 3], default: 2 },
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
}, {
timestamps: true,
toJSON: {
transform: (_document, value) => {
value.id = value._id.toString();
delete value._id;
delete value.__v;
delete value.owner;
return value;
},
},
});
taskSchema.index({ owner: 1, createdAt: -1 });
export const User = mongoose.model("User", userSchema);
export const Task = mongoose.model("Task", taskSchema);
Put this in one model module. It replaces 105's inline task schema and Task declaration; do not keep both. Thus Task is compiled once and every task serialization exposes id, never storage fields. unique: true builds a unique index, not a Mongoose validator. Registration must still catch duplicate-key error 11000. Existing tasks need owners before making the field required.
2. Hash and verify passwords
OWASP currently prefers Argon2id with at least 19 MiB memory, two iterations, and parallelism one. Node calls the salt a nonce and memory is measured in 1 KiB blocks.
import { argon2, randomBytes, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const derive = promisify(argon2);
const CURRENT_PASSWORD = {
passwordAlgorithm: "argon2id",
passwordVersion: 1,
passwordParams: { parallelism: 1, tagLength: 32, memory: 19 * 1024, passes: 2 },
};
const DUMMY = { ...CURRENT_PASSWORD, passwordSalt: randomBytes(16), passwordHash: Buffer.alloc(32) };
async function derivePassword(password, record) {
if (record.passwordVersion !== 1 || record.passwordAlgorithm !== "argon2id") {
throw new Error("Unsupported password encoding");
}
return derive(record.passwordAlgorithm, {
message: password,
nonce: record.passwordSalt,
...record.passwordParams,
});
}
async function hashPassword(password) {
const record = { ...CURRENT_PASSWORD, passwordSalt: randomBytes(16) };
return { ...record, passwordHash: await derivePassword(password, record) };
}
async function passwordMatches(password, user) {
const record = user ?? DUMMY;
const actual = await derivePassword(password, record);
const expected = record.passwordHash;
return timingSafeEqual(actual, expected) && Boolean(user);
}
Use asynchronous hashing so the event loop is not synchronously blocked. The stored algorithm, application encoding version, parameters, salt, and tag form one coherent verification record; verification uses that record rather than today's defaults, enabling a rehash after a successful login when policy changes. Benchmark parameters on deployment hardware and throttle login because expensive hashing can itself be abused. If the runtime lacks Argon2id, OWASP's next choice is scrypt with documented parameters. Plaintext, reversible encryption, MD5, SHA-1, and plain SHA-256 are not password storage.
3. Configure a server-side session
import session from "express-session";
import MongoStore from "connect-mongo";
const production = process.env.NODE_ENV === "production";
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 32) {
throw new Error("SESSION_SECRET must contain at least 32 unpredictable characters");
}
if (production) app.set("trust proxy", 1); // only for the known TLS proxy
app.use(session({
name: production ? "__Host-id" : "id",
secret: process.env.SESSION_SECRET,
store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI }),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: production,
sameSite: "strict",
path: "/",
maxAge: 1000 * 60 * 60,
},
}));
Generate SESSION_SECRET with a cryptographically secure generator, store it in deployment secrets/environment, and never prefix it VITE_. The development cookie cannot be Secure over plain http://localhost; production must use HTTPS and Secure. The __Host- prefix requires Secure, Path=/, and no Domain. Configure trust proxy only to match the real proxy topology, not blindly.
Keep the 105 explicit CORS allowlist and credentials: true. Place this unsafe-method origin check immediately after CORS and before JSON parsing, sessions, and routes:
app.use((req, res, next) => {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
if (allowedOrigins.includes(req.get("Origin"))) return next();
res.status(403).json({
error: { code: "ORIGIN_REJECTED", message: "Request origin rejected" },
});
});
Retain 105's CORS callback so a disallowed preflight also receives its recognized 403 envelope, along with malformed-JSON 400 and oversized-body 413 handling. For a same-origin production build, include that exact origin. This concise control suits the guide, but review proxy/header handling and add a maintained, session-bound CSRF solution for a real application.
4. Register, login, inspect, and logout
function regenerate(req) {
return new Promise((resolve, reject) => req.session.regenerate((err) => err ? reject(err) : resolve()));
}
function destroy(req) {
return new Promise((resolve, reject) => req.session.destroy((err) => err ? reject(err) : resolve()));
}
function publicUser(user) { return { id: user._id.toString(), email: user.email }; }
function error(res, status, code, message, details) {
const body = { code, message };
if (details) body.details = details;
return res.status(status).json({ error: body });
}
function validEmail(email) {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function isObjectBody(body) {
return body !== null && typeof body === "object" && !Array.isArray(body);
}
app.post("/api/auth/register", async (req, res) => {
if (!isObjectBody(req.body)) {
return error(res, 400, "INVALID_REQUEST_BODY", "Request body must be an object");
}
const email = typeof req.body.email === "string" ? req.body.email.trim().toLowerCase() : "";
const password = typeof req.body.password === "string" ? req.body.password : "";
if (!validEmail(email) || password.length < 15 || password.length > 128) {
return error(res, 400, "VALIDATION_ERROR", "Invalid account", [
"use a valid email of at most 254 characters and a 15-128 character password",
]);
}
try {
const user = await User.create({ email, ...await hashPassword(password) });
await regenerate(req);
req.session.userId = user._id.toString();
res.status(201).json({ data: publicUser(user) });
} catch (err) {
if (err.code === 11000) return error(res, 400, "REGISTRATION_FAILED", "Unable to create account");
throw err;
}
});
app.post("/api/auth/login", async (req, res) => {
if (!isObjectBody(req.body)) {
return error(res, 400, "INVALID_REQUEST_BODY", "Request body must be an object");
}
const email = typeof req.body.email === "string" ? req.body.email.trim().toLowerCase() : "";
const suppliedPassword = typeof req.body.password === "string" ? req.body.password : "";
const password = suppliedPassword.length <= 128 ? suppliedPassword : "";
const user = validEmail(email)
? await User.findOne({ email }).select(
"+passwordAlgorithm +passwordVersion +passwordParams +passwordHash +passwordSalt"
)
: null;
if (!await passwordMatches(password, user) || suppliedPassword.length > 128) {
return error(res, 401, "INVALID_CREDENTIALS", "Invalid email or password");
}
await regenerate(req); // rotate the ID after the privilege change
req.session.userId = user._id.toString();
res.json({ data: publicUser(user) });
});
app.get("/api/auth/me", async (req, res) => {
if (!req.session.userId) return error(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required");
const user = await User.findById(req.session.userId);
if (!user) return error(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required");
res.json({ data: publicUser(user) });
});
app.post("/api/auth/logout", async (req, res) => {
await destroy(req);
res.clearCookie(production ? "__Host-id" : "id", { path: "/" });
res.status(204).end();
});
Registration never returns the hash. The server bounds and validates email independently of Mongoose. Login applies the 128-character maximum before hashing, gives the same message for unknown email and wrong password, and performs a dummy derivation for broadly similar work. Regeneration counters session fixation; logout invalidates server state rather than merely changing React.
5. Authenticate and authorize every task operation
import mongoose from "mongoose";
import { Task } from "./models.js";
import { taskInput } from "./task-input.js";
function requireUser(req, res, next) {
if (!req.session.userId) return error(res, 401, "AUTHENTICATION_REQUIRED", "Authentication required");
next();
}
function requireTaskId(req, res, next) {
if (!mongoose.isObjectIdOrHexString(req.params.id)) {
return error(res, 400, "VALIDATION_ERROR", "Invalid task", [
"id must be a valid ObjectId",
]);
}
next();
}
app.get("/api/tasks", requireUser, async (req, res) => {
const limit = req.query.limit === undefined ? 50 : Number(req.query.limit);
if (Object.keys(req.query).some((key) => key !== "limit") ||
!Number.isInteger(limit) || limit < 1 || limit > 100) {
return error(res, 400, "VALIDATION_ERROR", "Invalid query", ["only limit=1..100 is supported"]);
}
const tasks = await Task.find({ owner: req.session.userId })
.sort({ createdAt: -1 }).limit(limit);
res.json({ data: tasks });
});
app.post("/api/tasks", requireUser, async (req, res) => {
const input = taskInput(req.body);
const task = await Task.create({ ...input, owner: req.session.userId });
res.status(201).location(`/api/tasks/${task.id}`).json({ data: task });
});
app.patch("/api/tasks/:id", requireUser, requireTaskId, async (req, res) => {
const changes = taskInput(req.body, { partial: true });
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, owner: req.session.userId }, { $set: changes },
{ returnDocument: "after", runValidators: true },
);
if (!task) return error(res, 404, "TASK_NOT_FOUND", "Task not found");
res.json({ data: task });
});
Move 105's strict taskInput unchanged into task-input.js and export it; it still accepts the 104 fields title, completed, and priority, and rejects empty PATCH bodies, unknown fields, and wrong primitive types. Apply the same combined filter to delete, and use requireTaskId on that route too so malformed IDs return the documented coded 400 instead of reaching Mongoose. A 404 does not reveal whether another user owns the ID. Never fetch by ID, send data, and check ownership later. Never accept owner from the request body.
On React startup, show an authentication-checking state while calling GET /auth/me. Render the login form on 401, task UI on success, and a retryable error for network/500 failures. Keep credentials: "include" in the API service. Password inputs need labels and autocomplete="current-password"; registration uses autocomplete="new-password". Do not disable paste or password managers.
Accessibility, security, and performance checkpoint
Announce login errors, preserve keyboard focus, expose pending states, and use standard form controls that work with password managers. Use TLS, generic login responses, bounded password/body lengths, secure cookies, server-side ownership checks, and sanitized logs. Enforce tested IP/account login throttles and cap concurrent expensive password derivations with deployment-aware controls; an in-memory counter or tiny middleware snippet is not production-ready across multiple processes. Benchmark Argon2 on deployment hardware; index task ownership queries; limit and paginate growing lists. Never trade away authorization or password-hash strength as a performance shortcut.
Intermediate and optional advanced
Intermediate: preserve the intended route after login; show a non-revealing login error in role="alert"; disable Submit while pending; focus the error summary; add a visible logout control. Test with two accounts and copy one task ID into the other's request.
Core limitations to document
The origin check plus SameSite=Strict cookie is defense in depth, not a complete CSRF protocol. Add a maintained, session-bound CSRF token before allowing cross-site or relaxed-cookie flows. Add shared-store account/IP throttling, an Argon2 concurrency cap, secure headers/CSP, audit logging, password recovery, email verification, MFA/passkeys, secret rotation, and alerting before production. Do not describe an in-memory counter or Express's default MemoryStore as production-ready.
Optional advanced: add synchronizer CSRF tokens, password reset, verified email, MFA/passkeys, session revocation, security logs, and re-authentication. Prefer a mature identity provider when requirements grow; do not replace this revocable session with a long-lived browser-stored bearer token.
Mistakes and debugging
- Cookie absent: inspect
Set-Cookie; verify Fetchcredentials, CORS exact origin, HTTPS/Secure, proxy trust, and cookie domain/path. - Every user sees every task: a route omitted
ownerfrom its query. - Password field appears in JSON: schema selection/serialization is wrong; treat it as an incident.
- Duplicate email becomes
500: handle Mongo error11000; rememberuniqueis an index. 403on POST: inspectOriginand allowlist; do not "fix" it with*.
Tiered exercises
- Foundation: label each step in login as AuthN, session management, or AuthZ.
- Core: convert 105 delete to a protected ownership query.
- Stretch: specify five tests proving account isolation and session invalidation.
Foundation: finding the user and verifying Argon2 is AuthN; regenerating/storing the session and cookie is session management; task ownership is AuthZ. Login itself does not authorize a particular task.
Core:
app.delete("/api/tasks/:id", requireUser, requireTaskId, async (req, res) => {
const task = await Task.findOneAndDelete({
_id: req.params.id,
owner: req.session.userId,
});
if (!task) return error(res, 404, "TASK_NOT_FOUND", "Task not found");
res.status(204).end();
});
Stretch tests: unauthenticated list returns 401; user A sees A's task; user B cannot read/update/delete A's task; changing an ID does not disclose ownership and returns 404; after logout the old cookie cannot access tasks. Also verify the database stores a salt/hash rather than the entered password and that login failures use one generic response.
Recap
AuthN answers "who?", AuthZ answers "may this identity do this here?", and sessions connect independent requests. Passwords are slow-hashed with a unique salt. The browser architecture keeps an opaque ID in a secure cookie and state server-side. CSRF, XSS, brute force, session fixation, and object ownership are separate risks requiring separate controls.
Direct official references (checked 2026-08-24)
- OWASP: Authentication Cheat Sheet
- OWASP: Authorization Cheat Sheet
- OWASP: Password Storage Cheat Sheet
- OWASP: Session Management Cheat Sheet
- OWASP: CSRF Prevention Cheat Sheet
- OWASP: JSON Web Token Cheat Sheet
- Node.js 24 LTS:
crypto.argon2 - Express: session middleware
- Express: production security
- MongoDB: Unique indexes
Web security interview matrix
| Threat | Example | Primary controls |
|---|---|---|
| XSS | task title becomes executable HTML | contextual escaping and safe rendering |
| CSP | injected script loads from attacker origin | strict script-src with nonces/hashes |
| CSRF | cross-site form sends cookie-authenticated POST | SameSite plus session-bound token/origin checks |
| SSRF | user URL makes server fetch metadata | allowlisted hosts, private-range blocking, egress controls, timeouts |
| injection | client supplies Mongo operators or shell fragments | typed allowlists and parameterized APIs |
| abuse | login/hash or list endpoint is exhausted | shared rate limits, body/page caps, concurrency limits |
Never put secrets in VITE_ variables, browser storage, logs, URLs, or error responses. HttpOnly protects cookie reading, not XSS request forgery. CSP reduces exploitability but does not replace output safety. Test a literal <img onerror=...> title, CSP headers, cross-site unsafe requests, private-IP SSRF attempts, operator-shaped JSON, oversized bodies, and distributed rate-limit behavior. A strong interview answer names the asset, boundary, exploit, control, residual risk, and test.
