This final chapter puts the whole course to work. Below is a small notes API of the kind found in countless tutorials. It works, its tests pass, and it contains a dozen flaws from earlier lessons. Your task is to find them, map each to its lesson, and arrive at the hardened version in the sample code.
Treat it as a real review: list findings with severity, fix them, then prove it.
const express = require("express");
const jwt = require("jsonwebtoken");
const { exec } = require("node:child_process");
const SECRET = "notes-app-secret"; // (1)
const app = express();
app.use(express.json()); // (2)
app.post("/login", async (req, res) => {
const user = await User.findOne({ email: req.body.email, password: req.body.password }); // (3)(4)
if (!user) return res.status(401).send("No user with that email"); // (5)
res.cookie("token", jwt.sign({ id: user._id, role: user.role }, SECRET)); // (6)(7)
res.send("ok");
});
app.get("/notes/:id", async (req, res) => {
res.json(await Note.findById(req.params.id)); // (8)
});
app.post("/notes", async (req, res) => {
const user = jwt.decode(req.cookies.token); // (9)
res.json(await Note.create({ ...req.body, ownerId: user.id })); // (10)
});
app.get("/export", (req, res) => {
exec(`zip -r /tmp/${req.query.name}.zip /data/notes`, () => res.download(`/tmp/${req.query.name}.zip`)); // (11)
});
app.use((err, req, res, next) => res.status(500).send(err.stack)); // (12)
app.listen(3000);| # | Finding | Severity | Lesson |
|---|---|---|---|
| 1 | Hard-coded signing secret | High | Secrets Management |
| 2 | No body limit, helmet or rate limit | Medium | Secure Coding in Node.js |
| 3 | Plain-text password compared in the query | Critical | Passwords and Hashing |
| 4 | Raw body objects in the query (operator injection) | High | Injection Beyond SQL |
| 5 | Login error reveals whether the email exists | Low | Authentication Failures |
| 6 | Cookie without HttpOnly, Secure, SameSite | High | Cookies and Sessions |
| 7 | JWT with no expiry; algorithm not pinned | High | JWT Pitfalls |
| 8 | Any user can read any note by ID | Critical | Broken Access Control |
| 9 | jwt.decode trusts an unverified token | Critical | JWT Pitfalls |
| 10 | Mass assignment: body may set ownerId | High | Input Validation |
| 11 | Shell command and path built from user input | Critical | Injection Beyond SQL |
| 12 | Stack traces returned to clients | Medium | Security Misconfiguration |
The sample code is the result: keys from a validated config module; helmet, a bounded parser and a rate limiter as the baseline; ownerId: req.user.id in every note query; bodies through a strict schema with the owner taken from the session; the export feature removed until it can be built without a shell. The authentication module handles the rest:
// auth.js (excerpt): verified token, pinned algorithm, hardened cookie
const payload = jwt.verify(token, config.JWT_PUBLIC_KEY, { algorithms: ["RS256"], issuer: config.ISSUER });
res.cookie("token", signed, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 15 * 60_000 });Login hashes with Argon2id, verifies a dummy hash for unknown users, and returns one generic message; the error handler logs internally and answers generically.
Run the checks from the testing lesson.
npm audit --audit-level=high # dependencies
semgrep scan --config p/nodejs --config p/express # the exec() finding disappears
npm test # 404 for foreign notes, 400 for operator objects
docker run --rm ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t https://staging.example.comIf every finding has a test or scan behind it, the app is ready.
Finding 8 lets any authenticated user read any note. What is the correct fix?
What to learn next: the Express.js and REST APIs courses for backend hardening in context, the Docker and Linux courses for runtime and host security, then deliberately vulnerable training applications and capture-the-flag exercises on systems you are authorized to test.