Secret Mission Board API
Build a REST API with JWT auth and role-based access control.
Introduction
30 Second Summary
Every app with user accounts faces the same question: who gets to do what? A basic login check tells you who someone is. But real applications need to answer a harder question: what is this person allowed to do, and what should they never see?
In this project, you will build a classified mission management API for a fictional spy agency. Three roles (Operative, Handler, Director) interact with missions at different classification levels, and your API enforces exactly who can see, create, and modify what.
What You'll Build
When you test the finished API, you will send requests as different agents and see completely different responses. An Operative gets blocked from another agent's mission report. A Handler gets a 403 when reaching for Top Secret content. A Director sees everything and can promote other agents on the spot.
By the end of this project, you'll have:
- A working JWT authentication system where agents register, log in, and receive tokens that embed their role and clearance level.
- A composable role-based access control middleware that layers role checks, clearance-level checks, and resource ownership enforcement on every protected route.
- Real access denial in action: test as a Handler and get a 403 with a clear explanation of exactly why you were blocked from a Top Secret mission.
- Secret Mission: An audit trail that logs every authorization denial with the who, what, and why for security monitoring.
Are there any prerequisites?
This project assumes basic Node.js and Express experience (routing, middleware concepts). You should also be familiar with REST APIs and JSON.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project and Database
Every authorization system needs something to protect. Before you can lock down routes with roles and clearance levels, you need a running server and a database that models who your users are and what they can access.
In this step, you will scaffold the project, install four key packages, and create a SQLite database with tables for agents and classified missions. You will also seed a Director account so you always have an admin ready to bootstrap the system.
In this step, get ready to:
- Initialize the project folder and install Express, better-sqlite3, jsonwebtoken, and bcryptjs.
- Create a SQLite database schema with users and missions tables.
- Start the Express server and verify it connects to the database.
Initialize the project and install dependencies
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secret-mission-board
cd secret-mission-board
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON summary of your new package.json printed in the terminal.
- Install the four dependencies your project needs by running:
npm install express better-sqlite3 jsonwebtoken bcryptjs
What are these packages?
- express is the web framework that handles HTTP requests and routing.
- better-sqlite3 gives you a fast, synchronous SQLite database with zero setup.
- jsonwebtoken creates and verifies JWTs for authentication.
- bcryptjs hashes passwords so you never store them in plain text.
You should see output confirming all four packages were added.
- Create the folder structure for your source code by running:
mkdir src
mkdir src/middleware
mkdir src/routes
- Confirm the folders exist by running:
ls src
You should see middleware and routes listed in the output.
Not seeing the folders?
Make sure you ran cd secret-mission-board before creating the folders. If you are in the wrong directory, the src folder will not exist here.
Run pwd (macOS/Linux) or cd (Windows) to check your current location. You should see a path ending in secret-mission-board.
Help me find my project folder and create the src directory structure.
Create the database schema
Your database needs two tables. The users table stores agent accounts with their role and clearance level. The missions table stores classified operations with their security classification.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the secret-mission-board folder on your Desktop, then click Open.
- Create a new file called src/database.js by right-clicking the src folder in the Explorer sidebar and selecting New File.
- Add the following code to set up the database connection and users table:
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
// Connect to SQLite (creates missions.db if it doesn't exist)
const db = new Database('missions.db');
function initializeDatabase() {
// Create the users table for agent accounts
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'operative',
clearance_level INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)
`);
}
What does this code do?
- new Database('missions.db') creates a SQLite database file in your project root. If the file already exists, it connects to it instead.
- The users table stores each agent's credentials, their role (operative, handler, or director), and their clearance_level (1 through 3).
- CREATE TABLE IF NOT EXISTS means the table is only created when it does not already exist, making the function safe to call multiple times.
- Add the missions table and the Director seed below the users table creation, replacing the closing } of the function with the following:
// Create the missions table for classified operations
db.exec(`
CREATE TABLE IF NOT EXISTS missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
classification TEXT NOT NULL DEFAULT 'CONFIDENTIAL',
status TEXT NOT NULL DEFAULT 'pending',
assigned_to INTEGER,
created_by INTEGER NOT NULL,
report TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (assigned_to) REFERENCES users(id),
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Seed default Director if not exists
const existingDirector = db.prepare('SELECT id FROM users WHERE username = ?').get('director');
if (!existingDirector) {
const hashedPassword = bcrypt.hashSync('classified123', 10);
db.prepare(
'INSERT INTO users (username, password, role, clearance_level) VALUES (?, ?, ?, ?)'
).run('director', hashedPassword, 'director', 3);
console.log('Default Director account created (username: director, password: classified123)');
}
console.log('Database initialized');
}
module.exports = { db, initializeDatabase };
What does this code do?
- The missions table stores each mission's classification level (CONFIDENTIAL, SECRET, or TOP_SECRET), who it is assigned to, and who created it.
- The Director seed checks if a director user already exists before inserting one. This gives you an admin account to bootstrap the entire system.
- bcrypt.hashSync('classified123', 10) hashes the password with 10 salt rounds so it is never stored in plain text.
- Save src/database.js.
- Test that your database initializes correctly by running this in your terminal:
node -e "require('./src/database').initializeDatabase()"
You should see two lines printed:
Default Director account created (username: director, password: classified123)
Database initialized
Seeing a module not found error?
If you see Cannot find module './src/database', make sure you are running the command from the secret-mission-board root folder, not from inside src/.
If you see a syntax error, double-check that you have the closing }); for both db.exec calls and the closing } for the initializeDatabase function.
Help me fix my database.js file so it initializes without errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/database.js and move on to the next substep.
ⓧ I'd like to double check the full code
Scroll up and confirm your src/database.js contains both chunks in order:
- Chunk 1: imports, database connection (new Database('missions.db')), and the users table creation inside initializeDatabase().
- Chunk 2: the missions table creation, Director seed logic, console.log('Database initialized'), closing brace, and module.exports.
Create the config and server entry point
Your server needs a configuration file for JWT settings and an entry point that starts Express and connects everything together.
- Create a new file called src/config.js in the src folder and add:
// JWT configuration constants
module.exports = {
JWT_SECRET: 'super-secret-spy-key-change-in-production',
JWT_EXPIRY: '1h'
};
What does this code do?
JWT_SECRET is the key used to sign and verify tokens. In production you would use an environment variable, but a hardcoded string works for local development.
JWT_EXPIRY sets tokens to expire after 1 hour, forcing agents to re-authenticate.
- Save src/config.js.
- Create a new file called src/index.js in the src folder and add the following server code:
const express = require('express');
const { initializeDatabase } = require('./database');
const app = express();
const PORT = 3000;
// Parse incoming JSON request bodies
app.use(express.json());
// Set up database tables and seed data
initializeDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- express() creates a new Express application instance.
- express.json() is middleware that automatically parses JSON request bodies so you can read req.body in your route handlers.
- initializeDatabase() runs the table creation and Director seeding you just wrote.
- app.listen(PORT) starts the server and prints a confirmation message when it is ready to accept requests.
- Save src/index.js.
Before you run the server, what do you expect to see in the terminal? Remember that the Director account was already created when you tested the database earlier.
- Start your server by running:
node src/index.js
You should see this output:
Database initialized
Server running on port 3000
The Director message does not appear this time because the account already exists from your earlier test. The server is now running and waiting for requests on port 3000.
Stop the server when done
Press Ctrl+C in your terminal to stop the server. You will restart it throughout the project as you add new features.
Server not starting?
If you see Cannot find module './database', make sure src/index.js and src/database.js are both inside the src/ folder. The require path ./database is relative to index.js, not to the project root.
If port 3000 is already in use, you may have another server running. Stop it with Ctrl+C in that terminal, then try again.
Help me debug why my Express server won't start.
✔️ Awesome, I've got everything!
Great. Confirm you have saved both src/config.js and src/index.js.
ⓧ I'd like to double check the full code
Here is the complete src/index.js:
const express = require('express');
const { initializeDatabase } = require('./database');
const app = express();
const PORT = 3000;
app.use(express.json());
initializeDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
And here is the complete src/config.js:
module.exports = {
JWT_SECRET: 'super-secret-spy-key-change-in-production',
JWT_EXPIRY: '1h'
};
Your foundation is locked in. You have a running Express server backed by a SQLite database with two tables and a Director account ready to go. Next up, you will build the authentication system that lets agents register, log in, and prove their identity with JWTs.
Build Authentication - Register and Login
Your server is running, but right now anyone could send any request and claim to be anyone. Without authentication, there is no way to prove identity.
In this step, you will build endpoints that let agents register and log in. When an agent logs in, they receive a JWT (JSON Web Token) that carries their role and clearance level. Every future authorization check in this project depends on that token.
In this step, get ready to:
- Build a registration endpoint that hashes passwords and creates new operatives.
- Build a login endpoint that verifies credentials and issues a signed JWT.
- Create an authenticate middleware that protects routes using Bearer tokens.
Create the auth routes file
The registration and login endpoints live in their own route file. You will start by scaffolding the file with the imports and router setup that both endpoints share.
- Create a new file at src/routes/auth.js by running:
touch src/routes/auth.js
- Open src/routes/auth.js in your editor and add the file scaffold:
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { db } = require('../database');
const { JWT_SECRET, JWT_EXPIRY } = require('../config');
const router = express.Router();
module.exports = router;
What does this code do?
This imports the libraries you need for auth: bcryptjs for password hashing, jsonwebtoken for creating tokens, and your database and config modules.
The express.Router() creates a mini-app that you will mount onto the main server in a moment.
- Now add the register endpoint between the const router line and module.exports:
// Register a new operative
router.post('/register', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required.' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters.' });
}
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existingUser) {
return res.status(409).json({ error: 'Username already exists.' });
}
// Hash the password so it is never stored in plain text
const hashedPassword = bcrypt.hashSync(password, 10);
const result = db.prepare(
'INSERT INTO users (username, password, role, clearance_level) VALUES (?, ?, ?, ?)'
).run(username, hashedPassword, 'operative', 1);
res.status(201).json({
id: result.lastInsertRowid,
username,
role: 'operative',
clearanceLevel: 1
});
});
What does this code do?
- Validates that both username and password are present, and that the password is at least 6 characters.
- Checks the database for a duplicate username before inserting.
- Uses bcrypt.hashSync to hash the password with 10 salt rounds so the raw password is never stored.
- Inserts the new user with the default role of operative and clearance level 1 (Confidential).
- Returns the created user info without the password hash.
- Save src/routes/auth.js.
Before you can test this endpoint, you need to wire the routes file into your Express server.
- Open src/index.js and find this line:
const { initializeDatabase } = require('./database');
- Add this line directly below it:
const authRoutes = require('./routes/auth');
- Now find the app.use(express.json()) line and add this below it:
app.use('/api/auth', authRoutes);
- Save src/index.js.
- Stop your running server with Ctrl+C and restart it using the dev script for auto-reload:
npm run dev
Why npm run dev?
The dev script uses node --watch which automatically restarts the server whenever you save a file. You will not need to manually restart for the rest of this project.
You should see "Server running on port 3000" and "Database initialized" in the terminal.
- Test the register endpoint by running this in a new terminal:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "shadow", "password": "agent007"}'
You should see a JSON response with the new user's id, username, role, and clearanceLevel fields. No password hash appears in the response.
Getting connection refused?
- Make sure npm run dev is running in your other terminal and shows "Server running on port 3000".
- Check that src/index.js has app.use('/api/auth', authRoutes) after app.use(express.json()).
- If you see a module not found error, confirm your file is saved at src/routes/auth.js (not routes/auth.js without the src/ prefix).
help me debug my register endpoint
Add the login endpoint
Registration creates an agent, but the agent still cannot prove who they are on future requests. The login endpoint verifies credentials and issues a signed JWT that the agent includes with every subsequent request.
- In src/routes/auth.js, find the module.exports = router; line at the bottom of the file.
- Add this login handler directly above it:
// Log in and receive a signed JWT
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required.' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
// Compare submitted password against stored hash
const validPassword = bcrypt.compareSync(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
// Sign a token carrying the agent's identity and access info
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role,
clearanceLevel: user.clearance_level
},
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: JWT_EXPIRY }
);
res.json({ token, user: { id: user.id, username: user.username, role: user.role, clearanceLevel: user.clearance_level } });
});
What does this code do?
- Looks up the user by username. If no match, returns a generic "Invalid credentials" message (never revealing whether the username exists).
- Uses bcrypt.compareSync to check the submitted password against the stored hash.
- Signs a JWT with jwt.sign embedding the agent's id, username, role, and clearanceLevel directly in the token payload.
- The token uses HS256 algorithm and expires after 1 hour.
- Save src/routes/auth.js. Your server auto-restarts thanks to node --watch.
- Test the login endpoint with the agent you just registered:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "shadow", "password": "agent007"}'
You should see a JSON response containing a token field (a long encoded string) and a user object with role and clearanceLevel fields.
Getting "Invalid credentials"?
- Double-check the username and password match exactly what you registered (shadow and agent007).
- If you restarted the project and deleted the missions.db file, you need to register again first.
help me debug my login endpoint
Create the authenticate middleware
You have working registration and login, but protected routes need a way to verify the token on incoming requests. The authenticate middleware extracts the JWT from the request header, verifies it, and makes the decoded payload available to downstream route handlers.
- Create the middleware file by running:
touch src/middleware/authenticate.js
- Open src/middleware/authenticate.js and add the following:
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
// Expect "Bearer <token>" format
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
// Verify token signature and decode payload
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
module.exports = { authenticate };
What does this code do?
- Reads the Authorization header and expects the format Bearer <token>.
- Uses jwt.verify with algorithms: ['HS256'] to prevent algorithm downgrade attacks.
- On success, attaches the decoded token (containing id, username, role, clearanceLevel) to req.user so route handlers can use it.
- On failure, returns a 401 immediately without calling next().
- Save src/middleware/authenticate.js.
- Return to src/index.js and find the authRoutes require line you added earlier.
- Add this import directly below it:
const { authenticate } = require('./middleware/authenticate');
- Save src/index.js. Your server auto-restarts and should show "Server running on port 3000" with no errors.
Why import authenticate now?
You are not using authenticate on any route yet. You are importing it now so it is ready for Step 3, where you will protect the mission endpoints. The server restarting without errors confirms the middleware file is correctly structured.
Before you run the final test, predict what you will see in the token payload when you decode it. Which fields did jwt.sign embed?
- Log in as the seeded Director to get a token:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "director", "password": "classified123"}'
- Copy the token value from the response (the long string between the quotes).
- Open https://jwt.io in your browser.
- Paste the token into the Encoded field on the left.
In the Decoded Payload section on the right, you should see username set to director, role set to director, and clearanceLevel set to 3.
Seeing "Invalid Signature" on jwt.io?
That warning is expected. jwt.io shows "Invalid Signature" because it does not have your secret key. The decoded payload is still correct and readable. The important thing is that you see the role and clearanceLevel fields.
help me understand the JWT I'm seeing on jwt.io
✔️ Awesome, I've got everything!
Great work. Double check you have saved both src/routes/auth.js and src/index.js.
ⓧ I'd like to double check the full code
Here is the full src/routes/auth.js at this point. Scroll up and confirm your file has the imports at the top, the register handler, the login handler, and module.exports at the bottom.
Here is src/middleware/authenticate.js:
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
module.exports = { authenticate };
Here is src/index.js:
const express = require('express');
const { initializeDatabase } = require('./database');
const authRoutes = require('./routes/auth');
const { authenticate } = require('./middleware/authenticate');
const app = express();
const PORT = 3000;
app.use(express.json());
app.use('/api/auth', authRoutes);
initializeDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your authentication system is complete. Agents can register, log in, and receive tokens that prove their identity. Next up, you will build the mission endpoints and see what happens when you rely on role checks alone.
Add Role Middleware and Mission CRUD
Authentication proves who a user is. Authorization decides what that user can do. Your API can verify tokens, but every route is still wide open to anyone with a valid login.
In this step you will build a role-checking middleware and wire up mission CRUD endpoints. You will also discover a real security gap: role checks alone do not stop a low-clearance user from viewing top-secret data.
In this step, get ready to:
- Create a composable requireRole middleware that blocks requests from unauthorized roles.
- Build full CRUD routes for missions with role gates on dangerous operations.
- Expose a clearance-level security hole that basic role checks cannot catch.
Create the role-checking middleware
Every protected route needs to check whether the authenticated user holds the right role. A factory function that accepts a list of allowed roles and returns Express middleware keeps things composable and reusable across routes.
- Create a new file src/middleware/authorize.js and add the following code:
// Check if user has one of the allowed roles
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated.' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient role',
required: allowedRoles,
current: req.user.role
});
}
next();
};
}
// Map classification names to numeric levels
function classificationToLevel(classification) {
const levels = {
CONFIDENTIAL: 1,
SECRET: 2,
TOP_SECRET: 3
};
return levels[classification] || 1;
}
module.exports = { requireRole, classificationToLevel };
What does this code do?
requireRole(...allowedRoles) is a middleware factory. You pass in role names like 'handler', 'director' and get back a middleware function that checks req.user.role against that list. If the role does not match, it returns a 403 with a descriptive error showing what was required versus what the user actually has.
classificationToLevel maps mission classification strings to numeric levels. You will use this in the next step when you add clearance filtering.
- Save src/middleware/authorize.js.
- Confirm the file exists by running:
ls src/middleware/
You should see both authenticate.js and authorize.js listed.
Only see authenticate.js?
Make sure you created the file inside src/middleware/ and not in the project root. Double-check the filename spelling matches exactly.
Still stuck? Why isn't my authorize.js file appearing in src/middleware/?
Build the mission routes
Missions are the core resource in your API. Handlers create them, Operatives work on them, and Directors have full control. You will build five endpoints now and gate the dangerous ones with requireRole.
- Create a new file src/routes/missions.js and add the imports plus the POST route:
const express = require('express');
const { db } = require('../database');
const { requireRole, classificationToLevel } = require('../middleware/authorize');
const router = express.Router();
// Create a mission (Handler and Director only)
router.post('/', requireRole('handler', 'director'), (req, res) => {
const { title, description, classification, assigned_to } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required.' });
}
const validClassifications = ['CONFIDENTIAL', 'SECRET', 'TOP_SECRET'];
const missionClassification = classification || 'CONFIDENTIAL';
if (!validClassifications.includes(missionClassification)) {
return res.status(400).json({ error: 'Invalid classification. Use CONFIDENTIAL, SECRET, or TOP_SECRET.' });
}
const result = db.prepare(
'INSERT INTO missions (title, description, classification, assigned_to, created_by) VALUES (?, ?, ?, ?, ?)'
).run(title, description || null, missionClassification, assigned_to || null, req.user.id);
res.status(201).json({
id: result.lastInsertRowid,
title,
description,
classification: missionClassification,
assigned_to: assigned_to || null,
created_by: req.user.id,
status: 'pending'
});
});
What does this route do?
requireRole('handler', 'director') runs before the handler function. If an operative tries to POST here, they get a 403 before the route body ever executes.
The route validates the title is present and that the classification is one of the three allowed values. It then inserts the mission into SQLite and returns the created resource.
- Add the remaining routes below the POST handler in the same file:
// List all missions (all authenticated users)
router.get('/', (req, res) => {
const missions = db.prepare('SELECT * FROM missions').all();
res.json(missions);
});
// Get a single mission by ID
router.get('/:id', (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
res.json(mission);
});
module.exports = router;
Why no role check on GET?
Any authenticated user should be able to see missions relevant to them. The plan is to filter what they see based on clearance level. For now, both GET routes return data to anyone with a valid token. You will tighten this in the next step.
- Save src/routes/missions.js.
- Confirm the new file exists by running:
ls src/routes/
You should see auth.js and missions.js listed.
Wire up routes and expose the security gap
The routes exist as files but Express does not know about them yet. You also need a stub for user management and the PATCH/DELETE endpoints for missions. Once everything is wired, you will test role access and discover the clearance-level hole.
- Open src/routes/missions.js and find the line that says module.exports = router; at the bottom.
- Add the following two routes directly above that line:
// Update mission report (any authenticated user for now)
router.patch('/:id/report', (req, res) => {
const { report } = req.body;
if (!report) {
return res.status(400).json({ error: 'Report content is required.' });
}
db.prepare('UPDATE missions SET report = ? WHERE id = ?').run(report, req.params.id);
const updated = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
res.json(updated);
});
// Delete a mission (Director only)
router.delete('/:id', requireRole('director'), (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
db.prepare('DELETE FROM missions WHERE id = ?').run(req.params.id);
res.json({ message: 'Mission deleted.', id: parseInt(req.params.id) });
});
What do these routes do?
PATCH /:id/report lets an agent submit a field report for a mission. Right now any authenticated user can update any mission's report. You will add ownership checks in a later step.
DELETE /:id is gated behind requireRole('director'). Only Directors can permanently destroy a mission record.
- Save src/routes/missions.js.
- Now create src/routes/users.js with a stub for user listing:
const express = require('express');
const { db } = require('../database');
const { requireRole } = require('../middleware/authorize');
const router = express.Router();
// List all users (Director only)
router.get('/', requireRole('director'), (req, res) => {
const users = db.prepare('SELECT id, username, role, clearance_level, created_at FROM users').all();
res.json(users);
});
module.exports = router;
What does this route do?
This gives Directors a way to see all registered agents. It excludes the password hash from the response so credentials are never exposed in API output.
- Save src/routes/users.js.
- Now update src/index.js to wire the new routes. Replace the entire file content with:
const express = require('express');
const { initializeDatabase } = require('./database');
const authRoutes = require('./routes/auth');
const missionRoutes = require('./routes/missions');
const userRoutes = require('./routes/users');
const { authenticate } = require('./middleware/authenticate');
const app = express();
const PORT = 3000;
app.use(express.json());
// Public routes
app.use('/api/auth', authRoutes);
// Protected routes
app.use('/api/missions', authenticate, missionRoutes);
app.use('/api/users', authenticate, userRoutes);
// Initialize database and start server
initializeDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What changed in index.js?
You imported missionRoutes and userRoutes, then mounted them at /api/missions and /api/users. Both pass through the authenticate middleware first, so only requests with a valid JWT reach the route handlers.
- Save src/index.js.
- If your server is still running from the previous step, stop it with Ctrl+C.
- Delete the old database so you start fresh, then restart the server:
rm -f missions.db
node src/index.js
You should see Database initialized and Server running on port 3000 in your terminal.
Seeing a module not found error?
If the error mentions ./routes/missions or ./routes/users, confirm the files are saved inside src/routes/ with the exact filenames missions.js and users.js.
If it mentions ./middleware/authorize, check that authorize.js is inside src/middleware/.
Help me debug a module not found error when starting my Express server.
Time to test role-based access. Open a second terminal so you can send requests while the server runs.
- Log in as the Director to get a token by running:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "director", "password": "classified123"}'
You will get a JSON response containing a token field. Copy that token value.
- Use the Director's token to create a Top Secret mission:
curl -X POST http://localhost:3000/api/missions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN" \
-d '{"title": "Operation Nightfall", "classification": "TOP_SECRET"}'
Replace YOUR_DIRECTOR_TOKEN with the actual token from the login response. You should see a 201 response with the mission details.
- Now register a new user (they will default to role "operative" with clearance level 1):
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "agent-smith", "password": "password123"}'
- Log in as the new agent to get their token:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "agent-smith", "password": "password123"}'
Copy the new token from this response. This user has clearance level 1 (Confidential only).
Before you run the next command, think about what you expect to see. This user has Confidential clearance. The mission you created is classified as TOP_SECRET. Should they be able to see it?
- List all missions using the agent's token:
curl http://localhost:3000/api/missions \
-H "Authorization: Bearer YOUR_AGENT_TOKEN"
Replace YOUR_AGENT_TOKEN with the token you just copied.
The TOP_SECRET mission appears in the response. An agent with Confidential clearance can see classified material they should never have access to. Your role middleware blocks unauthorized actions, but it does nothing to filter content by classification level.
This security gap is intentional
Basic role checks answer "can this user type do this action?" but they cannot answer "is this user cleared to see this classification?" That is a different axis of authorization entirely.
You will fix this gap in the next step by adding a clearance-level middleware that filters missions based on the numeric level embedded in the user's token.
✔️ Awesome, I've got everything!
Great. Double-check you have saved all four files: authorize.js, missions.js, users.js, and index.js.
ⓧ I'd like to double check the full code
Here is each file you created or modified in this step. Confirm yours matches.
// Check if user has one of the allowed roles
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated.' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient role',
required: allowedRoles,
current: req.user.role
});
}
next();
};
}
// Map classification names to numeric levels
function classificationToLevel(classification) {
const levels = {
CONFIDENTIAL: 1,
SECRET: 2,
TOP_SECRET: 3
};
return levels[classification] || 1;
}
module.exports = { requireRole, classificationToLevel };
const express = require('express');
const { db } = require('../database');
const { requireRole } = require('../middleware/authorize');
const router = express.Router();
// List all users (Director only)
router.get('/', requireRole('director'), (req, res) => {
const users = db.prepare('SELECT id, username, role, clearance_level, created_at FROM users').all();
res.json(users);
});
module.exports = router;
const express = require('express');
const { initializeDatabase } = require('./database');
const authRoutes = require('./routes/auth');
const missionRoutes = require('./routes/missions');
const userRoutes = require('./routes/users');
const { authenticate } = require('./middleware/authenticate');
const app = express();
const PORT = 3000;
app.use(express.json());
// Public routes
app.use('/api/auth', authRoutes);
// Protected routes
app.use('/api/missions', authenticate, missionRoutes);
app.use('/api/users', authenticate, userRoutes);
// Initialize database and start server
initializeDatabase();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
For src/routes/missions.js, scroll up and confirm you have: the imports and POST route from the first chunk, the GET routes from the second chunk, and the PATCH/DELETE routes you added above the module.exports line. The file should have five route handlers total (POST, GET /, GET /:id, PATCH /:id/report, DELETE /:id) followed by module.exports = router; at the very end.
You now have a working RBAC middleware and full mission CRUD. The role gates are solid, but the clearance gap is a real vulnerability. In the next step, you will layer clearance-level authorization on top and close this hole for good.
Enforce Clearance-Level Authorization
In the last step, you proved that role-based middleware works. A Handler can create missions while an Operative cannot. But you also uncovered a gap: a Handler with Confidential clearance could still see Top Secret missions in the API response.
Role checks answer "what can this type of user do?" but not "what classification level can they access?" In this step, you'll add clearance-level authorization that answers the second question. This is the RBAC + attribute pattern used in real production APIs.
In this step, get ready to:
- Add a requireClearance middleware that blocks users below a required clearance level.
- Update mission endpoints to filter and block based on classification vs. clearance.
- Add a Director-only promote endpoint so clearance changes take immediate effect.
Add the requireClearance middleware
Your authorize.js already has requireRole and classificationToLevel. Now you need a second gatekeeper that compares a user's clearance level against a required threshold.
- In src/middleware/authorize.js, find the closing brace of the classificationToLevel function, then copy the following code below it:
function requireClearance(level) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated.' });
}
if (req.user.clearanceLevel < level) {
return res.status(403).json({
error: 'Insufficient clearance',
required: level,
current: req.user.clearanceLevel
});
}
next();
};
}
What does this code do?
This middleware factory works just like requireRole but checks a numeric clearance level instead of a role string.
- It reads req.user.clearanceLevel from the decoded JWT payload.
- If the user's level is below the required threshold, it returns a 403 with both the required and current values so the client knows exactly why access was denied.
- Now find the module.exports line at the bottom of the file:
module.exports = { requireRole, classificationToLevel };
- Replace it with this line to export the new middleware:
module.exports = { requireRole, requireClearance, classificationToLevel };
- Save src/middleware/authorize.js.
- If your server is running with node --watch, it restarts automatically. Otherwise, stop it with Ctrl+C and restart by running:
node src/index.js
- You should see Server running on port 3000 and Database initialized with no errors. The new middleware is ready to use.
Seeing a syntax error on startup?
- Make sure requireClearance is a complete function with matching opening and closing braces.
- Check that the module.exports line has requireClearance between requireRole and classificationToLevel, separated by commas.
Help me debug a syntax error in my authorize.js middleware file.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file and your server restarted cleanly.
ⓧ I'd like to double check the full code
Your src/middleware/authorize.js should now have three functions in this order:
- requireRole(...allowedRoles) — checks the user's role against an allowed list.
- classificationToLevel(classification) — maps classification strings to numbers.
- requireClearance(level) — checks the user's clearance level against a required threshold.
The module.exports line should export all three: { requireRole, requireClearance, classificationToLevel }.
Enforce clearance on mission routes
The middleware exists, but nobody calls it yet. Now you'll update the mission endpoints so clearance is checked at three points: when creating a mission, when listing missions, and when viewing a single mission.
- In src/routes/missions.js, find this section inside the POST / handler:
if (!validClassifications.includes(missionClassification)) {
return res.status(400).json({ error: 'Invalid classification. Use CONFIDENTIAL, SECRET, or TOP_SECRET.' });
}
const result = db.prepare(
- Add the following clearance check between the classification validation and the db.prepare call, so it looks like this:
if (!validClassifications.includes(missionClassification)) {
return res.status(400).json({ error: 'Invalid classification. Use CONFIDENTIAL, SECRET, or TOP_SECRET.' });
}
// Check if creator has clearance to create this classification level
const requiredLevel = classificationToLevel(missionClassification);
if (req.user.clearanceLevel < requiredLevel) {
return res.status(403).json({
error: 'Insufficient clearance to create mission at this classification level.',
required: requiredLevel,
current: req.user.clearanceLevel
});
}
const result = db.prepare(
What does this check do?
Before the mission is inserted into the database, the handler converts the mission's classification to a numeric level using classificationToLevel. If the creating user's clearance is below that number, the request is rejected with a 403.
This prevents a Handler with clearance 1 (Confidential) from creating Secret or Top Secret missions they shouldn't even know exist.
- Save src/routes/missions.js and let the server restart.
What do you think will happen if your Handler (clearance 1) tries to create a TOP_SECRET mission now?
- Log in as your Handler from the previous step to get a fresh token by running:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "handler1", "password": "secret123"}'
Use your Handler's actual credentials
Replace handler1 and secret123 with the username and password you used when creating your Handler in the previous step. Copy the token value from the response.
- Attempt to create a TOP_SECRET mission using the Handler's token:
curl -X POST http://localhost:3000/api/missions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_HANDLER_TOKEN" \
-d '{"title": "Steal the plans", "classification": "TOP_SECRET"}'
You should see a 403 response with the clearance mismatch details:
{
"error": "Insufficient clearance to create mission at this classification level.",
"required": 3,
"current": 1
}
Still seeing a 201 success?
- Make sure you saved missions.js and the server restarted.
- Confirm the clearance check comes before the db.prepare INSERT statement, not after it.
- Check that you're using the Handler's token, not the Director's. The Director has clearance 3 and would succeed.
Help me figure out why my clearance check isn't blocking mission creation.
Now tackle the list endpoint. The current GET / handler returns every mission regardless of clearance. You'll replace it with a version that filters by the user's clearance level.
- In src/routes/missions.js, find the existing GET / handler:
router.get('/', (req, res) => {
const missions = db.prepare('SELECT * FROM missions').all();
res.json(missions);
});
- Replace the entire handler with this clearance-filtered version:
// List missions (filtered by clearance)
router.get('/', (req, res) => {
const userClearance = req.user.clearanceLevel;
const missions = db.prepare(`
SELECT m.*, u.username as assigned_to_username
FROM missions m
LEFT JOIN users u ON m.assigned_to = u.id
WHERE (
CASE m.classification
WHEN 'CONFIDENTIAL' THEN 1
WHEN 'SECRET' THEN 2
WHEN 'TOP_SECRET' THEN 3
ELSE 1
END
) <= ?
`).all(userClearance);
// Operatives only see missions assigned to them
if (req.user.role === 'operative') {
const myMissions = missions.filter(m => m.assigned_to === req.user.id);
return res.json(myMissions);
}
res.json(missions);
});
What does this query do?
The SQL CASE expression converts each mission's classification string into a numeric level right inside the query. Only missions at or below the user's clearance level are returned.
After the query, a second filter kicks in for Operatives specifically. They only see missions assigned to them, even within their clearance. Handlers and Directors see all missions within their clearance.
- Now find the existing GET /:id handler:
router.get('/:id', (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
res.json(mission);
});
- Replace it with this version that checks clearance before returning:
// Get single mission (with clearance check)
router.get('/:id', (req, res) => {
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(req.params.id);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
const requiredLevel = classificationToLevel(mission.classification);
if (req.user.clearanceLevel < requiredLevel) {
return res.status(403).json({
error: 'Insufficient clearance',
required: requiredLevel,
current: req.user.clearanceLevel
});
}
// Operatives can only view their own missions
if (req.user.role === 'operative' && mission.assigned_to !== req.user.id) {
return res.status(403).json({ error: 'You can only view missions assigned to you.' });
}
res.json(mission);
});
Why check clearance before ownership?
The order matters. A clearance denial tells the user "this mission is above your classification." An ownership denial tells them "this mission isn't assigned to you." By checking clearance first, you avoid leaking the fact that a high-classification mission even exists to a low-clearance user.
- Save src/routes/missions.js and let the server restart.
What do you predict will happen when your Handler requests the missions list now?
- Test the list endpoint as your Handler:
curl http://localhost:3000/api/missions \
-H "Authorization: Bearer YOUR_HANDLER_TOKEN"
The Top Secret mission is gone from the response. Only CONFIDENTIAL missions appear because your Handler has clearance level 1.
- Now try to access a Top Secret mission directly by its ID (use the ID from the mission you created as Director in the previous step):
curl http://localhost:3000/api/missions/1 \
-H "Authorization: Bearer YOUR_HANDLER_TOKEN"
You should see the clearance denial:
{
"error": "Insufficient clearance",
"required": 3,
"current": 1
}
Still seeing Top Secret missions in the list?
- Make sure you replaced the entire GET / handler, not just added code inside the old one.
- Check that you're using your Handler token, not the Director token. The Director has clearance 3 and sees everything.
- Confirm the SQL CASE statement ends with <= ? and passes userClearance as the parameter.
Help me debug why my clearance filter isn't working on the missions list.
Add the promote endpoint
Right now, there's no way for a Director to change someone's clearance level through the API. If a Handler earns trust and needs access to Secret missions, the Director needs an endpoint to promote them.
- Open src/routes/users.js and find the closing line of the existing GET / route:
router.get('/', requireRole('director'), (req, res) => {
const users = db.prepare('SELECT id, username, role, clearance_level, created_at FROM users').all();
res.json(users);
});
- Add the following promote endpoint below it, before module.exports:
// Promote/demote a user (Director only)
router.patch('/:id/promote', requireRole('director'), (req, res) => {
const { role, clearance_level } = req.body;
const userId = req.params.id;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const validRoles = ['operative', 'handler', 'director'];
if (role && !validRoles.includes(role)) {
return res.status(400).json({ error: 'Invalid role. Use operative, handler, or director.' });
}
if (clearance_level && (clearance_level < 1 || clearance_level > 3)) {
return res.status(400).json({ error: 'Clearance level must be between 1 and 3.' });
}
const newRole = role || user.role;
const newClearance = clearance_level || user.clearance_level;
db.prepare('UPDATE users SET role = ?, clearance_level = ? WHERE id = ?')
.run(newRole, newClearance, userId);
res.json({
id: parseInt(userId),
username: user.username,
role: newRole,
clearance_level: newClearance,
message: 'User updated. They will need to log in again for changes to take effect.'
});
});
What does this endpoint do?
- requireRole('director') ensures only Directors can promote or demote users.
- The endpoint accepts an optional role and clearance_level in the body. If either is omitted, the current value is kept.
- The response message reminds the caller that the promoted user must log in again. Their old JWT still carries the previous role and clearance until it expires or they get a new one.
- Save src/routes/users.js and let the server restart.
Why must the user log in again?
JWTs are stateless. The token the Handler already holds still says clearance 1 until it expires. Updating the database changes future tokens, not existing ones. The user must call /api/auth/login again to receive a fresh token reflecting their new clearance.
Time to test the full flow. Before you run the next commands, predict: after promoting the Handler to clearance 2, will they be able to see a SECRET mission? What about a TOP_SECRET mission?
- First, log in as the Director to get a Director token:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "director", "password": "classified123"}'
- Use the Director token to list users and find your Handler's ID:
curl http://localhost:3000/api/users \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN"
- Promote the Handler to clearance level 2 (replace HANDLER_ID with the actual number):
curl -X PATCH http://localhost:3000/api/users/HANDLER_ID/promote \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN" \
-d '{"clearance_level": 2}'
You should see the updated user info with clearance_level: 2 and the reminder about logging in again.
- Now log in as the Handler again to get a fresh token with the updated clearance:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "handler1", "password": "secret123"}'
- Create a SECRET mission using the Director token so you have one to test against:
curl -X POST http://localhost:3000/api/missions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN" \
-d '{"title": "Infiltrate the embassy", "classification": "SECRET"}'
- Test as the Handler with the new token. Request the missions list:
curl http://localhost:3000/api/missions \
-H "Authorization: Bearer YOUR_NEW_HANDLER_TOKEN"
The SECRET mission now appears in the response. The TOP_SECRET mission is still hidden. Your Handler has clearance 2 (Secret) but not clearance 3 (Top Secret).
- Confirm the block on the Top Secret mission by requesting it directly:
curl http://localhost:3000/api/missions/1 \
-H "Authorization: Bearer YOUR_NEW_HANDLER_TOKEN"
You should see the 403 with the clearance mismatch:
{
"error": "Insufficient clearance",
"required": 3,
"current": 2
}
Handler still sees Top Secret missions?
- Make sure you logged in as the Handler after promoting them. The old token still carries the previous clearance level.
- Verify the promote response shows clearance_level: 2, not 3.
- Check that you're using the new Handler token (from the second login), not the old one.
Help me debug why my promoted Handler still has the wrong clearance level.
✔️ Awesome, I've got everything!
Great. Double check you've saved all files and confirmed the Handler can see SECRET but not TOP_SECRET.
ⓧ I'd like to double check the full code
Your src/routes/users.js should have two routes in order:
- GET / — lists all users (Director only).
- PATCH /:id/promote — updates a user's role and clearance_level (Director only).
Your src/routes/missions.js should have these handlers in order:
- POST / — creates a mission with clearance check on the creator.
- GET / — lists missions filtered by clearance, with Operative-specific ownership filter.
- GET /:id — returns a single mission with clearance check and Operative ownership check.
- PATCH /:id/report — updates the mission report (unchanged from previous step).
- DELETE /:id — deletes a mission (Director only, unchanged).
Scroll up and confirm each chunk matches what you pasted.
The clearance gap from Step 3 is now sealed. Role checks control what actions a user can take, and clearance checks control what classification level they can access. Next up, you'll add the final layer: resource ownership checks that prevent one Operative from touching another Operative's mission report.
Add Resource Ownership Checks
Your API now checks both what role a user has and what clearance level they hold. These two layers cover a lot of ground.
But there is still a security gap. Right now, any authenticated user can PATCH any mission's report endpoint. An Operative with the right clearance could update another Operative's mission report. Ownership checks close this gap by ensuring users can only modify resources that belong to them, unless they have an overriding role like Director.
In this step, get ready to:
- Create an ownership middleware with role-override support.
- Apply the middleware to the mission report endpoint.
- Test the complete three-layer authorization chain.
Create the ownership middleware
The ownership middleware needs to answer one question: does this user own the resource they are trying to modify? It also needs an escape hatch for Director-level roles that should bypass the check entirely.
- Create a new file at src/middleware/ownership.js and add the following code:
const { db } = require('../database');
function requireOwnerOrRole(...overrideRoles) {
return (req, res, next) => {
const missionId = req.params.id;
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(missionId);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
// Directors can always bypass ownership
if (overrideRoles.includes(req.user.role)) {
req.mission = mission;
return next();
}
// Check if the user is the assigned operative
if (mission.assigned_to !== req.user.id) {
return res.status(403).json({
error: 'You can only update reports on missions assigned to you.'
});
}
req.mission = mission;
next();
};
}
module.exports = { requireOwnerOrRole };
What does this middleware do?
The requireOwnerOrRole function accepts a list of roles that can bypass the ownership check (like director).
It fetches the mission from the database, then checks two things in order. First, is the user's role in the override list? If yes, they pass through. Second, is the user the operative assigned to this mission? If not, they get a 403.
On success, it attaches the mission to req.mission so the route handler can use it without querying the database again.
- Save src/middleware/ownership.js.
- Confirm the file exists by running:
ls src/middleware/
You should see authenticate.js, authorize.js, and ownership.js listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
const { db } = require('../database');
function requireOwnerOrRole(...overrideRoles) {
return (req, res, next) => {
const missionId = req.params.id;
const mission = db.prepare('SELECT * FROM missions WHERE id = ?').get(missionId);
if (!mission) {
return res.status(404).json({ error: 'Mission not found.' });
}
// Directors can always bypass ownership
if (overrideRoles.includes(req.user.role)) {
req.mission = mission;
return next();
}
// Check if the user is the assigned operative
if (mission.assigned_to !== req.user.id) {
return res.status(403).json({
error: 'You can only update reports on missions assigned to you.'
});
}
req.mission = mission;
next();
};
}
module.exports = { requireOwnerOrRole };
Wire the ownership middleware into the report endpoint
The middleware exists, but nothing uses it yet. You need to import it in your missions routes file and apply it to the PATCH report endpoint.
- In src/routes/missions.js, find the import lines at the top of the file. Locate this line:
const { requireRole, classificationToLevel } = require('../middleware/authorize');
- Add this new import directly below it:
const { requireOwnerOrRole } = require('../middleware/ownership');
- Now scroll down to the PATCH report route. Find this line:
router.patch('/:id/report', (req, res) => {
- Replace it with this line, which adds the ownership middleware:
router.patch('/:id/report', requireOwnerOrRole('director'), (req, res) => {
What changed?
By passing requireOwnerOrRole('director') as middleware before the route handler, every PATCH request to this endpoint now runs through the ownership check first. Directors bypass it. Everyone else must be the assigned operative.
Handlers cannot update reports at all. They are not assigned operatives and they are not directors, so the middleware blocks them with a 403.
- Save src/routes/missions.js.
- Restart your server by pressing Ctrl+C in the terminal, then running:
node src/index.js
You should see Server running on port 3000 with no errors.
Seeing a module not found error?
Check that the require path in your import matches exactly: ../middleware/ownership. Make sure the file is saved as ownership.js inside the src/middleware/ folder.
Help me debug my module not found error in missions.js
Test the ownership enforcement
Time to prove the three-layer authorization chain works end to end. You will register two operatives, assign each to a different mission, then attempt a cross-operative report update.
- Stop your server with Ctrl+C.
- Delete the old database to start with a clean slate by running:
rm missions.db
- Restart the server by running:
node src/index.js
You should see both Default Director account created and Database initialized confirming a fresh database. The Director gets user id 1.
- Open a second terminal in your project folder.
- Register two operatives by running these commands:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "shadow", "password": "agent123"}'
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "phantom", "password": "agent456"}'
You should see two 201 responses. shadow gets id 2 and phantom gets id 3.
- Log in as the Director to get a token by running:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "director", "password": "classified123"}'
- Copy the token value from the response. You will use it in the next command.
- Create two missions and assign one to each operative by running these commands (replace YOUR_DIRECTOR_TOKEN with the token you just copied):
curl -X POST http://localhost:3000/api/missions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN" \
-d '{"title": "Infiltrate Lab", "classification": "CONFIDENTIAL", "assigned_to": 2}'
curl -X POST http://localhost:3000/api/missions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_DIRECTOR_TOKEN" \
-d '{"title": "Extract Asset", "classification": "CONFIDENTIAL", "assigned_to": 3}'
Mission 1 is assigned to shadow (id 2). Mission 2 is assigned to phantom (id 3).
- Log in as shadow to get an operative token by running:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "shadow", "password": "agent123"}'
- Copy shadow's token from the response.
Before you run the next command, predict what will happen. Shadow is about to try updating the report on mission 2, which belongs to phantom. Will it succeed or fail?
- Have shadow attempt to update phantom's mission report by running (replace YOUR_SHADOW_TOKEN with shadow's token):
curl -X PATCH http://localhost:3000/api/missions/2/report \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SHADOW_TOKEN" \
-d '{"report": "Attempting to hijack another agents mission"}'
You get a 403 response with the message: You can only update reports on missions assigned to you.. The ownership middleware blocked the request because shadow is not assigned to mission 2.
- Now have shadow update their own mission report (mission 1) by running:
curl -X PATCH http://localhost:3000/api/missions/1/report \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SHADOW_TOKEN" \
-d '{"report": "Lab infiltration complete. Security codes obtained."}'
This time you get a 200 response with the full mission object, including the report field now containing shadow's update.
What is the three-layer authorization chain?
Every request to the report endpoint now passes through three checks in sequence. First, authenticate verifies the JWT is valid. Second, requireOwnerOrRole checks resource ownership (with a Director bypass). Third, the route handler itself validates the request body.
Each layer returns a specific error. A missing token gives a 401. A wrong owner gives a 403 with a clear ownership message. A missing report body gives a 400. The client always knows exactly why a request was denied.
Still getting 200 on the cross-operative request?
Make sure you restarted the server after editing src/routes/missions.js. Changes to source files are not picked up automatically unless you use the npm run dev script.
Also verify that the PATCH route line now includes requireOwnerOrRole('director') between the path string and the (req, res) handler.
Help me figure out why my ownership check is not blocking the request
Your API now enforces authorization at three distinct layers: role, clearance, and ownership. Every denied request tells the client exactly why it was blocked. This is the complete RBAC system. Next up, you will take on a secret mission to add an audit trail that logs every denial for security monitoring.
Secret mission
Add an Authorization Audit Trail
In production systems, every denied access attempt should be logged for security monitoring. A spike in 403s from one user might indicate probing or a compromised account. Can you build a centralized audit trail that captures every authorization denial with the who, what, and why?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The secret-mission-board project folder on your Desktop.
- The missions.db SQLite database file (inside the project folder).
- Node.js dependencies installed in node_modules/ (inside the project folder).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment further with the API.
- The SQLite database file missions.db persists between runs. Your seeded Director account, test users, and missions all remain.
- Restart the server anytime with node src/index.js from inside the project folder.
Pause - I'll come back to this later
Shut down the running Node.js process to free up your terminal. Your files and database stay intact.
- Press Ctrl+C in the terminal where the server is running to stop the process.
- When you're ready to come back, open a terminal in the secret-mission-board folder and run node src/index.js.
Delete - I don't want to use this again
Remove all project files and start fresh. Since everything is local, deleting the project folder removes everything.
- Press Ctrl+C in the terminal to stop the server if it is still running.
- Navigate to your Desktop and delete the entire secret-mission-board folder by running:
macOS
cd ~/Desktop
rm -rf secret-mission-board
Windows
cd ~\Desktop
rmdir /s /q secret-mission-board
This removes all source files, the missions.db database, and the node_modules/ dependencies. Nothing else to clean up.
Nice Work!
Nice Work!
Nice work! You just built a production-grade classified mission management API with multi-layered authorization using Node.js and Express.
You've learned how to:
- Built a complete REST API with JWT authentication that embeds role and clearance data into tokens, then enforced access with composable middleware.
- Implemented three-layer authorization: role-based middleware, clearance-level attribute checks, and resource ownership enforcement.
- Designed descriptive error responses that tell clients exactly why access was denied, and built a Director-only user management system to promote and demote agents.
- Secret Mission: Added a production-style audit trail capturing every authorization denial for security monitoring.
Ready to quiz yourself?