Skip to Content
Lab 2 (AI)

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:

  1. Write an engineering brief with explicit acceptance criteria.
  2. Direct Claude Code, acting in the AI as Builder role, to plan and then implement the work.
  3. Verify the result against your acceptance criteria.
  4. Commission an automated security audit of the entire authentication flow.
  5. 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)

  1. 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
  2. Create every file listed in the Starter Code section below, preserving the folder structure shown in each filename.

  3. Install the dependencies:

    npm init -y npm install express mongoose bcrypt jsonwebtoken dotenv
  4. Copy .env.example to .env and fill in your own values. Use your local MongoDB connection string or a MongoDB Atlas connection string for MONGO_URI, and choose a long, random string for JWT_SECRET. The .gitignore file already excludes .env; never commit it.

  5. Start the server to confirm the starter runs:

    node server.js

    You should see API server listening on localhost:3001. In an API client such as Postman or Insomnia, send GET http://localhost:3001/api/notes. You should receive a 501 response with the message Auth middleware not implemented yet. That is the stub doing its job; by the end of Part 2 this same request will return a 401 without a token and a 200 with one.

  6. Verify Claude Code is installed and authenticated. If you have not installed it yet:

    npm install -g @anthropic-ai/claude-code

    Then run claude inside the project directory and use /login to sign in with your Per Scholas-provided Claude account.

  7. Create a CLAUDE.md file 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.
  8. 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.

Note

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.

.gitignore
node_modules .env
.env.example
# 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=yoursupersecretjwttoken
server.js
require('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}`)); });
config/connection.js
const mongoose = require('mongoose'); mongoose.connect(process.env.MONGO_URI); module.exports = mongoose.connection;
models/index.js
const User = require('./User'); const Note = require('./Note'); module.exports = { User, Note };
models/User.js
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;
models/Note.js
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;
utils/auth.js
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 }); }, };
routes/index.js
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;
routes/api/index.js
const router = require('express').Router(); const userRoutes = require('./userRoutes'); const noteRoutes = require('./noteRoutes'); router.use('/users', userRoutes); router.use('/notes', noteRoutes); module.exports = router;
routes/api/userRoutes.js
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;
routes/api/noteRoutes.js
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.

  1. Create BRIEF.md at 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.
  2. Create PROMPTS.md at 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:
  3. Commit both files: git add BRIEF.md PROMPTS.md then git commit -m "Engineering brief and prompt log".

  4. Start Claude Code by running claude in 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.

  5. Give the Builder this kickoff prompt (copy it into PROMPTS.md as 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.
  6. 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 in PROMPTS.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.

  7. When the Builder reports it is done, review the work before you trust it:

    git diff

    Read 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 diff to confirm it, and record the correction in PROMPTS.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:

  1. Register two users. Send POST http://localhost:3001/api/users/register twice 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.

  2. Send GET http://localhost:3001/api/notes with no Authorization header. Expect 401.

  3. Send the same request with the header Authorization: Bearer not-a-real-token. Expect 401.

  4. As amina (header Authorization: Bearer <aminaToken>), send POST http://localhost:3001/api/notes with body { "title": "Sprint retro", "content": "Ship the auth fix" }. Expect 201, and the returned note’s user field should be amina’s _id. Create a second note as ben.

  5. As ben, send GET http://localhost:3001/api/notes. Expect only ben’s note in the response.

  6. As ben, send PUT http://localhost:3001/api/notes/<aminaNoteId> with body { "title": "Hacked" }. Expect 403. Repeat with DELETE. Expect 403.

  7. As amina, update and then delete her own note. Expect 200 for 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 push

Part 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.

  1. Exit Claude Code, then start a new session by running claude again in the project directory.

  2. 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.
  3. Read AUDIT.md carefully 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/:id route passes req.body straight into the update, so a request body containing a user field 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.

  1. Triage every finding. Add a Triage column (or an annotation under each row) to AUDIT.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.

  2. 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.password field contains the bcrypt hash.
    • As amina, send PUT http://localhost:3001/api/notes/<aminaNoteId> with body { "user": "<benUserId>" } (ben’s _id appears in his register response). The response shows the note’s owner changed, and amina’s next GET /api/notes no longer includes her own note.
  3. 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 diff before accepting them.

  4. 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
  5. Write your Engineering Manager review note. Append a section titled ## Engineering Manager Review to the end of AUDIT.md containing 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 to BRIEF.md next 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:

CriteriaExcellentSatisfactoryNeeds ImprovementPoints
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
Total50

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.md with the Builder working agreement.
  • BRIEF.md with your acceptance criteria.
  • PROMPTS.md recording your kickoff prompt, plan review outcome, at least one course correction, the audit prompt (and any rerun iterations), and the remediation prompt.
  • AUDIT.md containing 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 .env file and no node_modules directory committed anywhere in the history.
Caution

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.