Secure Record Storage with an AI Builder
In this lab you will act as an Engineering Manager directing an AI Builder (Claude Code) to implement JWT authentication middleware and ownership-based authorization in a Notes API, then commission an automated security audit, triage its findings, and direct the remediation. Writing precise acceptance criteria, reviewing an agent’s diffs, and deciding which audit findings are real are exactly the judgment skills professional teams now expect from engineers who work alongside AI tools.
Estimated time: 1.5 hours (asynchronous)
Scenario
A contractor delivered a “Notes” API to Innovate Inc. before leaving the company, and the delivery is incomplete. The authentication middleware in utils/auth.js is an unimplemented stub, so every protected route currently rejects all requests. Worse, the note routes were written with no concept of ownership: once the middleware works, any authenticated user will be able to view, update, or delete any note, regardless of who created it. An intake review also flagged possible security defects elsewhere in the code, but nobody has catalogued them.
You are the Engineering Manager assigned to finish and harden this API. You will not write the implementation code yourself. Instead, you will:
- Write an engineering brief with explicit acceptance criteria.
- Direct Claude Code, acting in the AI as Builder role, to plan and then implement the work.
- Verify the result against your acceptance criteria.
- Commission an automated security audit of the entire authentication flow.
- Triage the findings and direct the Builder to remediate the confirmed ones.
The Builder writes the code. You own the outcome.
Part 1: Set Up the Project (about 15 minutes)
-
Create a new project directory named
secure-notes-ai, initialize it as a git repository, and open it in your editor:mkdir secure-notes-ai cd secure-notes-ai git init -
Create every file listed in the Starter Code section below, preserving the folder structure shown in each filename.
-
Install the dependencies:
npm init -y npm install express mongoose bcrypt jsonwebtoken dotenv -
Copy
.env.exampleto.envand fill in your own values. Use your local MongoDB connection string or a MongoDB Atlas connection string forMONGO_URI, and choose a long, random string forJWT_SECRET. The.gitignorefile already excludes.env; never commit it. -
Start the server to confirm the starter runs:
node server.jsYou should see
API server listening on localhost:3001. In an API client such as Postman or Insomnia, sendGET http://localhost:3001/api/notes. You should receive a501response with the messageAuth middleware not implemented yet.That is the stub doing its job; by the end of Part 2 this same request will return a401without a token and a200with one. -
Verify Claude Code is installed and authenticated. If you have not installed it yet:
npm install -g @anthropic-ai/claude-codeThen run
claudeinside the project directory and use/loginto sign in with your Per Scholas-provided Claude account. -
Create a
CLAUDE.mdfile at the repository root. Claude Code reads this file at the start of every session, which makes it the right place to define the Builder’s standing rules. Use this content:CLAUDE.md# Project: Innovate Inc. Notes API ## Stack - Node.js with Express - MongoDB with Mongoose - jsonwebtoken for stateless authentication - bcrypt for password hashing ## Working Agreement You are the Builder on this project. I am the Engineering Manager. Follow these rules in every session: 1. Propose a plan and wait for my approval before writing or editing any code. 2. Modify only files inside this repository. Do not edit BRIEF.md, PROMPTS.md, or AUDIT.md unless I direct you to. 3. Secrets live only in the `.env` file. Never hardcode a secret in a source file and never read or print the contents of `.env`. 4. If an acceptance criterion in BRIEF.md is ambiguous, stop and ask me instead of guessing. 5. Do not install new dependencies without asking me first. -
Make your first commit so the starter state is preserved in history:
git add . git commit -m "Starter code with unimplemented auth middleware"Create a new GitHub repository and push this commit to it now, so your remote exists before the Builder starts working.
If you do not have access to a Pro-level Claude account through Per Scholas, you may complete this lab using the free tier of Claude or another AI assistant with equivalent capabilities. Free-tier usage limits may require you to complete the lab across more than one session. If you cannot use Claude Code at all, you can run every phase in a claude.ai chat instead: paste the Working Agreement from CLAUDE.md as your first message, paste the starter files, ask for a plan before any code, and apply the returned code changes to your files manually. On the free tier, split the Part 3 audit into two smaller messages (first utils/auth.js and routes/api/userRoutes.js, then routes/api/noteRoutes.js and the models) to stay within usage limits.
Starter Code
Create these files exactly as shown. This is the same Notes API used by Lab 2, with one difference: utils/auth.js contains only the signToken helper and an empty middleware stub. The ownership flaws in the note routes are intact, and the intake review suspects other problems too. Do not fix any of it by hand; that is the Builder’s job, under your direction.
node_modules
.env# Replace with your MongoDB Atlas connection string, or use a local database
MONGO_URI=mongodb://127.0.0.1:27017/notesdb
# Choose a long, random string for your JWT secret
JWT_SECRET=yoursupersecretjwttokenrequire('dotenv').config();
const express = require('express');
const path = require('path');
const db = require('./config/connection');
const routes = require('./routes');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// If the app is running in production, serve client/build as static assets
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '../client/build')));
}
app.use(routes);
db.once('open', () => {
app.listen(PORT, () => console.log(`API server listening on localhost:${PORT}`));
});const mongoose = require('mongoose');
mongoose.connect(process.env.MONGO_URI);
module.exports = mongoose.connection;const User = require('./User');
const Note = require('./Note');
module.exports = { User, Note };const { Schema, model } = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
match: [/.+@.+\..+/, 'Must use a valid email address'],
},
password: {
type: String,
required: true,
minlength: 5,
},
});
// hash user password
userSchema.pre('save', async function () {
if (this.isNew || this.isModified('password')) {
const saltRounds = 10;
this.password = await bcrypt.hash(this.password, saltRounds);
}
});
// custom method to compare and validate password for logging in
userSchema.methods.isCorrectPassword = async function (password) {
return bcrypt.compare(password, this.password);
};
const User = model('User', userSchema);
module.exports = User;const { Schema, model } = require('mongoose');
// The Builder will modify this model per the acceptance criteria in BRIEF.md
const noteSchema = new Schema({
title: {
type: String,
required: true,
trim: true,
},
content: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
const Note = model('Note', noteSchema);
module.exports = Note;const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
const expiration = '2h';
module.exports = {
// TODO: The Builder will implement this middleware.
// Requirements live in BRIEF.md. Until it is implemented,
// every request to a protected route receives a 501 response.
authMiddleware: function (req, res, next) {
return res.status(501).json({ message: 'Auth middleware not implemented yet.' });
},
signToken: function ({ username, email, _id }) {
const payload = { username, email, _id };
return jwt.sign({ data: payload }, secret, { expiresIn: expiration });
},
};const router = require('express').Router();
const apiRoutes = require('./api');
router.use('/api', apiRoutes);
router.use((req, res) => {
res.status(404).send('<h1>404 Error!</h1>');
});
module.exports = router;const router = require('express').Router();
const userRoutes = require('./userRoutes');
const noteRoutes = require('./noteRoutes');
router.use('/users', userRoutes);
router.use('/notes', noteRoutes);
module.exports = router;const router = require('express').Router();
const { User } = require('../../models');
const { signToken } = require('../../utils/auth');
// POST /api/users/register - Create a new user
router.post('/register', async (req, res) => {
try {
const user = await User.create(req.body);
const token = signToken(user);
res.status(201).json({ token, user });
} catch (err) {
res.status(400).json(err);
}
});
// POST /api/users/login - Authenticate a user and return a token
router.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user) {
return res.status(400).json({ message: 'No user found with this email address' });
}
const correctPw = await user.isCorrectPassword(req.body.password);
if (!correctPw) {
return res.status(400).json({ message: 'Wrong password!' });
}
const token = signToken(user);
res.json({ token, user });
});
module.exports = router;const router = require('express').Router();
const { Note } = require('../../models');
const { authMiddleware } = require('../../utils/auth');
// Apply authMiddleware to all routes in this file
router.use(authMiddleware);
// GET /api/notes - Get all notes for the logged-in user
// THIS ROUTE CURRENTLY HAS AN OWNERSHIP FLAW
router.get('/', async (req, res) => {
// This currently finds all notes in the database.
// It should only find notes owned by the logged-in user.
try {
const notes = await Note.find({});
res.json(notes);
} catch (err) {
res.status(500).json(err);
}
});
// POST /api/notes - Create a new note
router.post('/', async (req, res) => {
try {
const note = await Note.create({
...req.body,
// The owner needs to be assigned here
});
res.status(201).json(note);
} catch (err) {
res.status(400).json(err);
}
});
// PUT /api/notes/:id - Update a note
router.put('/:id', async (req, res) => {
try {
// This needs an authorization check
const note = await Note.findByIdAndUpdate(req.params.id, req.body, { returnDocument: 'after' });
if (!note) {
return res.status(404).json({ message: 'No note found with this id!' });
}
res.json(note);
} catch (err) {
res.status(500).json(err);
}
});
// DELETE /api/notes/:id - Delete a note
router.delete('/:id', async (req, res) => {
try {
// This needs an authorization check
const note = await Note.findByIdAndDelete(req.params.id);
if (!note) {
return res.status(404).json({ message: 'No note found with this id!' });
}
res.json({ message: 'Note deleted!' });
} catch (err) {
res.status(500).json(err);
}
});
module.exports = router;Part 2: Direct the Builder (about 25 minutes)
An Engineering Manager never hands an agent a vague request. The Builder is only as good as your brief.
-
Create
BRIEF.mdat the repository root. Write it in your own words, but it must contain every acceptance criterion below. These criteria carry the full scope of the original Lab 2 (ownership-based authorization) plus the middleware the contractor never finished:BRIEF.md# Engineering Brief: Secure the Notes API ## Task Implement the authentication middleware in utils/auth.js and add ownership-based authorization to the note routes. ## Acceptance Criteria 1. `authMiddleware` extracts the token from the `Authorization` header using the Bearer scheme only. Tokens sent in the request body or query string are ignored (tokens in URLs end up in server logs). 2. `authMiddleware` verifies the token signature and expiration with `jwt.verify` against `process.env.JWT_SECRET`, using a maximum age of 2 hours to match `signToken`. 3. On success, the decoded user payload is attached to `req.user`. Note: `signToken` wraps the payload in a `data` property, so attach the unwrapped payload; route handlers must be able to read `req.user._id`. 4. When the token is missing, malformed, expired, or invalid, the middleware returns a `401` status with a JSON error message and does not call `next()`. 5. The `Note` model gains a required `user` field of type `Schema.Types.ObjectId` with `ref: 'User'`. 6. `POST /api/notes` assigns the authenticated user's `_id` from `req.user` to the new note's `user` field. 7. `GET /api/notes` returns only the notes owned by the authenticated user. 8. `PUT /api/notes/:id` and `DELETE /api/notes/:id` return a `403` status with a JSON error message when the requester does not own the note. Owners can update and delete their own notes normally. 9. No hardcoded secrets anywhere. All configuration comes from `.env`. ## Out of Scope Do not modify the user registration or login routes. -
Create
PROMPTS.mdat the repository root. You will record every prompt you give the Builder, every plan decision, and every course correction here. Start it like this:PROMPTS.md# Prompt Log ## Phase 1: Build - Plan request: - Plan review outcome (approved as-is or revised, and why): - Course corrections: ## Phase 2: Audit - Audit prompt: - Rerun notes (if the first audit missed something): ## Phase 3: Remediation - Remediation prompt: -
Commit both files:
git add BRIEF.md PROMPTS.mdthengit commit -m "Engineering brief and prompt log". -
Start Claude Code by running
claudein the project directory. Press Shift+Tab to cycle permission modes until you are in Plan Mode. In Plan Mode, Claude Code proposes a plan without editing any files, which is exactly what you want before authorizing work. -
Give the Builder this kickoff prompt (copy it into
PROMPTS.mdas well):Read CLAUDE.md and BRIEF.md. Acting as the Builder, propose an implementation plan that satisfies every acceptance criterion in BRIEF.md. Do not write any code yet. Present the plan as a numbered list of file changes so I can approve or revise it. -
Review the plan as an Engineering Manager would. Ask yourself: Does the plan touch only the files you expect (
utils/auth.js,models/Note.js,routes/api/noteRoutes.js)? Does it address all nine criteria? Does it add anything you did not ask for? If anything is off, tell the Builder what to change and record the exchange inPROMPTS.md. When the plan is right, approve it and let the Builder exit Plan Mode and implement. Claude Code will ask permission before editing files and running commands; read each request and approve deliberately rather than reflexively. -
When the Builder reports it is done, review the work before you trust it:
git diffRead every changed line (VS Code’s Source Control panel works too). You are required to reject or revise at least one aspect of the generated work, at either the plan stage or the diff stage: an unclear variable name, a weak error message, a missed criterion, an unnecessary change, or anything else your review turns up. Direct the Builder to make the revision, re-run
git diffto confirm it, and record the correction inPROMPTS.md. If the work were genuinely flawless, tightening an error message still counts; in practice, your review will find something more substantial.
Checkpoint 1: Verify the Acceptance Criteria
Do not take the Builder’s word for it. With the server running (node server.js), verify each criterion in Postman or Insomnia:
-
Register two users. Send
POST http://localhost:3001/api/users/registertwice with JSON bodies such as:{ "username": "amina", "email": "amina@example.com", "password": "password123" }{ "username": "ben", "email": "ben@example.com", "password": "password456" }Save both tokens from the responses.
-
Send
GET http://localhost:3001/api/noteswith noAuthorizationheader. Expect401. -
Send the same request with the header
Authorization: Bearer not-a-real-token. Expect401. -
As amina (header
Authorization: Bearer <aminaToken>), sendPOST http://localhost:3001/api/noteswith body{ "title": "Sprint retro", "content": "Ship the auth fix" }. Expect201, and the returned note’suserfield should be amina’s_id. Create a second note as ben. -
As ben, send
GET http://localhost:3001/api/notes. Expect only ben’s note in the response. -
As ben, send
PUT http://localhost:3001/api/notes/<aminaNoteId>with body{ "title": "Hacked" }. Expect403. Repeat withDELETE. Expect403. -
As amina, update and then delete her own note. Expect
200for both.
If any test fails, direct the Builder to fix it (log the prompt in PROMPTS.md) and re-test. When everything passes, commit the implementation as its own commit:
git add .
git commit -m "Builder implementation: auth middleware and ownership authorization"
git pushPart 3: Commission a Security Audit (about 25 minutes)
The API now works, but working is not the same as secure. You will bring in a fresh set of eyes: the same agent, in a new session, acting as a security auditor. A fresh session matters because an auditor who remembers writing the code will tend to defend it instead of attacking it.
-
Exit Claude Code, then start a new session by running
claudeagain in the project directory. -
Give the auditor this prompt (copy it into
PROMPTS.md):Act as an application security auditor who has never seen this code before. Audit the entire authentication and authorization flow of this repository: utils/auth.js, routes/api/userRoutes.js, routes/api/noteRoutes.js, the models, and the server configuration. Check at least the following: secret management and hardcoded values; token expiration handling; Bearer token parsing edge cases; error messages that leak information or enable account enumeration; loose equality (==) in ownership comparisons; missing null or existence checks; mass assignment of protected fields on update routes; and sensitive data exposure in API responses. Write your findings to AUDIT.md as a table with these columns: ID, File, Finding, Severity (Critical, High, Medium, or Low), and Recommended Fix. Do not change any application code in this session. -
Read
AUDIT.mdcarefully when the auditor finishes.
Checkpoint 2: Did the Audit Find the Planted Vulnerabilities?
This starter ships with at least two known vulnerabilities that survive a faithful implementation of your brief, because they live outside its scope:
- Password hash exposure: the register and login routes return the entire user document, including the bcrypt password hash, in the JSON response.
- Mass assignment on update: the
PUT /api/notes/:idroute passesreq.bodystraight into the update, so a request body containing auserfield can reassign a note’s owner.
If AUDIT.md does not surface both, do not accept the audit. Refine your prompt (for example, point the auditor at the response bodies of specific routes, or ask it to trace what an attacker could place in req.body) and run the audit again in the same session. Document each iteration in the Phase 2 section of PROMPTS.md. Iterating on an under-scoped audit prompt is a normal part of directing AI tools, not a failure.
Part 4: Triage and Remediation (about 20 minutes)
An audit is raw material, not a work order. AI auditors surface real vulnerabilities, but they also flag issues that do not apply or that fall outside the task. Deciding which is which is your job.
-
Triage every finding. Add a
Triagecolumn (or an annotation under each row) toAUDIT.md. Mark each finding as one of:- Confirmed: a real vulnerability in this code that must be fixed.
- False Positive: the auditor is wrong about this code; say why.
- Out of Scope: real, but outside this lab’s boundaries (for example, rate limiting or HTTPS termination); say why.
Every triage decision needs a one-sentence justification in your own words.
-
Reproduce at least two Confirmed findings in your API client to prove the auditor was right. For the planted vulnerabilities:
- Register a new user and inspect the response body: the
user.passwordfield contains the bcrypt hash. - As amina, send
PUT http://localhost:3001/api/notes/<aminaNoteId>with body{ "user": "<benUserId>" }(ben’s_idappears in his register response). The response shows the note’s owner changed, and amina’s nextGET /api/notesno longer includes her own note.
- Register a new user and inspect the response body: the
-
Direct the remediation. In your Claude Code session, give the Builder a prompt like this (log it in
PROMPTS.md):Read AUDIT.md. Fix every finding whose triage decision is Confirmed. Do not change any behavior covered by the acceptance criteria in BRIEF.md except where a Confirmed finding requires it. Propose a plan first and wait for my approval before editing files.Review the plan, approve it, then review the changes with
git diffbefore accepting them. -
Re-test. Re-run the Checkpoint 1 tests to confirm nothing regressed, then re-run your two reproductions from step 2 and confirm they now fail (the hash no longer appears; the owner can no longer be reassigned). Commit the remediation as its own commit, separate from the implementation commit:
git add . git commit -m "Remediate confirmed audit findings" git push -
Write your Engineering Manager review note. Append a section titled
## Engineering Manager Reviewto the end ofAUDIT.mdcontaining 5 to 8 sentences that evaluate the Builder’s performance: what it implemented correctly on the first pass, what it got wrong or missed, how the audit quality held up under your reproductions, and one instruction you would add toBRIEF.mdnext time to get a better first pass. Commit and push this final change.
Grading
This lab is worth 50 points. Your submission will be evaluated based on the following criteria:
| Criteria | Excellent | Satisfactory | Needs Improvement | Points |
|---|---|---|---|---|
| Agent-Implemented Middleware and Authorization | (18-20 pts) All nine acceptance criteria in BRIEF.md pass the Checkpoint 1 tests: Bearer-only extraction, signature and expiration verification, req.user attachment, 401 handling, owner assignment on create, owner-filtered reads, and 403 on non-owner update and delete. | (14-17 pts) Most criteria pass, but one or two are flawed (for example, one route misses its ownership check or the middleware accepts tokens outside the Bearer header). | (0-13 pts) The middleware or the ownership authorization is missing, non-functional, or fails multiple acceptance criteria. | 20 |
| Security Audit and Triage | (13-15 pts) AUDIT.md surfaces both planted vulnerabilities, and every finding is triaged as Confirmed, False Positive, or Out of Scope with a clear one-sentence justification in the learner’s own words. | (10-12 pts) The audit misses one planted vulnerability, or several triage decisions lack justification. | (0-9 pts) AUDIT.md is missing, surfaces neither planted vulnerability, or contains no triage decisions. | 15 |
| Remediation | (9-10 pts) All Confirmed findings are fixed under the learner’s direction, the fixes live in one or more remediation commits separate from the implementation commit, and re-testing confirms the fixes without regressions. | (7-8 pts) Confirmed findings are fixed, but the commit history does not separate remediation from implementation, or re-testing is not evident. | (0-6 pts) Confirmed findings remain unfixed, or remediation broke previously passing acceptance criteria. | 10 |
| Engineering Manager Review Note | (5 pts) A 5-8 sentence review note in AUDIT.md substantively evaluates what the Builder did well, what it missed, and names one concrete instruction to add to the brief next time. PROMPTS.md shows at least one course correction. | (3-4 pts) The review note is present but shallow or incomplete, or PROMPTS.md shows no course correction. | (0-2 pts) The review note is missing or does not evaluate the agent’s work. | 5 |
| Total | 50 |
Submission Guidelines
Submit a link to your completed GitHub repository on Canvas. Before submitting, confirm your repository contains all of the following:
- The working Notes API source code, based on the provided starter.
CLAUDE.mdwith the Builder working agreement.BRIEF.mdwith your acceptance criteria.PROMPTS.mdrecording your kickoff prompt, plan review outcome, at least one course correction, the audit prompt (and any rerun iterations), and the remediation prompt.AUDIT.mdcontaining the auditor’s findings table, your triage decision and justification for every finding, and your Engineering Manager review note.- A commit history with at least three separate commits: the starter code, the Builder’s implementation, and the post-audit remediation.
- No
.envfile and nonode_modulesdirectory committed anywhere in the history.
Double-check that your .env file was never committed. If it was, your JWT_SECRET and database credentials are exposed in your repository history, which is itself the kind of finding your audit was designed to catch.