API Security with JWT and Swagger
Build a secure REST API with JWT auth, Swagger docs, and XSS defenses.
Introduction
30 Second Summary
Every time you log in to an app or save a document online, an API handles your request behind the scenes. If that API does not verify who you are or sanitize what you send, anyone can access your data or inject malicious code.
In this project, you will build a Secure Notes API from scratch using Express 5 and JSON Web Tokens. Along the way, you will generate interactive Swagger documentation, exploit and fix a live XSS vulnerability, and wire up a CI pipeline that tests your code on every push.
What You'll Build
Picture opening your browser to a fully interactive API docs page where you register a user, grab a token, create notes, and even trigger a live XSS attack to watch your security layers neutralize it instantly.
By the end of this project, you'll have:
- A JWT authentication system where you register, log in, and manage personal notes through protected endpoints with short-lived access tokens and refresh token rotation.
- Interactive Swagger UI documentation at /api-docs that lets you browse and test every endpoint from the browser, including authorizing protected routes with a pasted token.
- A live XSS exploit you trigger yourself, then lock down with Helmet security headers and DOMPurify input sanitization. Jest tests and a GitHub Actions pipeline prove the fix holds on every push.
- Secret Mission: Add role-based access control with an admin role that can view all users' notes.
Are there any prerequisites?
This project assumes you are comfortable with JavaScript and REST APIs. You need VS Code, Node.js v24, Python 3, and a GitHub account.
Before We Start
Before We Start...
Before you write a single line of code, take a moment to lock in what you are about to build and why it matters.
Set Up the Project and Install Dependencies
Building a secure API means getting the foundation right before writing a single route. You need a project folder, version control, and a set of battle-tested libraries for authentication, documentation, and security.
In this step, you will create the project, install Express and every security library the API depends on, and set up a minimal server that proves everything is wired together.
In this step, get ready to:
- Create the project folder and initialize Git.
- Install all production and dev dependencies.
- Set up the project files and verify the server starts.
Create the project directory and initialize npm
Every Node.js project starts with a folder and a package.json file that tracks your dependencies. You will also initialize Git so every change is version-controlled from the start.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir secure-notes-api
cd secure-notes-api
- Initialize an npm project by running:
npm init -y
You should see the generated package.json contents printed in your terminal.
Now check whether Git is installed by running:
git --version
✔️ I see a version number
Git is ready. Move on to the next command.
ⓧ Command not found
Git is not installed yet. Install it by running:
winget install --id Git.Git -e --source winget
After the installation completes, close and reopen PowerShell, then navigate back to your project folder:
cd ~/Desktop/secure-notes-api
- Run git --version again to confirm Git is now available.
- Initialize a Git repository by running:
git init
You should see a message confirming the repository was initialized.
- Open the project in VS Code by running:
code .
Why open VS Code this way?
Running code . opens VS Code with your secure-notes-api folder already loaded. From here on, you will use the integrated terminal inside VS Code for all remaining commands.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
Install production dependencies
Your API needs ten production libraries covering authentication, security headers, input sanitization, API documentation, rate limiting, and environment configuration.
- Install all production dependencies by running this command in the VS Code integrated terminal:
npm install express@5.2.1 jsonwebtoken@9.0.3 bcrypt@6.0.0 swagger-jsdoc@6.3.0 swagger-ui-express@5.0.1 helmet@8.3.0 isomorphic-dompurify@3.22.0 express-rate-limit@8.6.2 cookie-parser@1.4.7 dotenv@16.5.0
What are all these packages?
- express is the web framework that handles routing and middleware.
- jsonwebtoken signs and verifies JWT tokens for authentication.
- bcrypt hashes passwords so they are never stored in plain text.
- swagger-jsdoc and swagger-ui-express auto-generate interactive API documentation.
- helmet sets security headers like Content-Security-Policy to block common attacks.
- isomorphic-dompurify sanitizes user input to prevent XSS attacks.
- express-rate-limit throttles repeated requests to protect against brute-force attacks.
- cookie-parser and dotenv handle cookies and environment variables respectively.
The install may take a minute. You should see a summary of added packages when it finishes.
Seeing errors about bcrypt or node-gyp?
The bcrypt package requires native compilation. On Windows, this needs the Visual Studio Build Tools with the C++ workload installed. If the install fails, you can swap in a pure-JavaScript alternative by running:
npm uninstall bcrypt
npm install bcryptjs@3.0.2
The bcryptjs package has the same API, so no code changes are needed.
Help me fix my bcrypt installation error on Windows.
- Verify Express loaded correctly by running:
node -e "const e = require('express'); console.log('Express', e.name || 'loaded')"
You should see Express loaded printed without errors.
Next, your project needs a .env file to hold secret values that should never be committed to version control. JWT tokens are signed using these secrets, so they need to exist before the server starts.
- In the VS Code Explorer sidebar, click the New File icon (the file icon with a plus sign).
- Name the file .env and press Enter.
- Paste the following environment variables into the file:
JWT_SECRET=your-super-secret-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
PORT=3000
What do these variables do?
- JWT_SECRET is the key used to sign short-lived access tokens.
- JWT_REFRESH_SECRET is a separate key for signing refresh tokens, so compromising one does not compromise the other.
- PORT sets which port the server listens on.
- Save the file with Ctrl+S.
These are development-only values
The placeholder secrets in this .env file are fine for local development. In a production environment, you would replace them with long, randomly generated strings and store them in a secrets manager.
✔️ Awesome, I've got everything!
Great. Make sure you have saved .env.
ⓧ I'd like to double check the full code
Your .env file should contain exactly this:
JWT_SECRET=your-super-secret-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
PORT=3000
You also need a .gitignore file to prevent node_modules/ and your .env secrets from being committed to Git.
- Click the New File icon in the Explorer sidebar again.
- Name the file .gitignore and press Enter.
- Paste the following into the file:
node_modules/
.env
Why ignore these files?
The node_modules/ folder contains thousands of dependency files that anyone can regenerate with npm install. The .env file holds secrets that must never appear in a public repository.
- Save .gitignore with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you have saved .gitignore.
ⓧ I'd like to double check the full code
Your .gitignore file should contain exactly this:
node_modules/
.env
Install dev dependencies and configure the project structure
The project uses Jest for automated tests and supertest for making HTTP requests inside those tests. These are dev dependencies because they are only needed during development and CI, not when the API runs in production.
- Install the dev dependencies by running:
npm install --save-dev jest@30.4.2 supertest@7.2.2
You should see the packages added to the devDependencies section in the terminal output.
Now update package.json so it has the correct entry point and scripts for starting the server and running tests.
- Open package.json in the Explorer sidebar.
- Find these lines near the top of the file:
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
- Replace that entire section with the following:
"description": "Secure Notes API with JWT, Swagger, and XSS Protection",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest --forceExit --detectOpenHandles"
},
What do these scripts do?
- start lets you run npm start to launch the server.
- test runs your Jest test suite. The --forceExit and --detectOpenHandles flags ensure Jest shuts down cleanly after tests finish, even if the Express server is still listening.
- Save package.json with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Confirm you saved package.json.
ⓧ I'd like to double check the full code
Your package.json should look like this (dependency versions are exact):
{
"name": "secure-notes-api",
"version": "1.0.0",
"description": "Secure Notes API with JWT, Swagger, and XSS Protection",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest --forceExit --detectOpenHandles"
},
"dependencies": {
"bcrypt": "6.0.0",
"cookie-parser": "1.4.7",
"dotenv": "16.5.0",
"express": "5.2.1",
"express-rate-limit": "8.6.2",
"helmet": "8.3.0",
"isomorphic-dompurify": "3.22.0",
"jsonwebtoken": "9.0.3",
"swagger-jsdoc": "6.3.0",
"swagger-ui-express": "5.0.1"
},
"devDependencies": {
"jest": "30.4.2",
"supertest": "7.2.2"
}
}
Next, create the folder structure the project needs. The src/ folder holds all application code, with subfolders for routes and middleware. The tests/ folder holds automated tests.
- Create all the project folders by running:
mkdir src/routes, src/middleware, tests
- Confirm the folders were created by running:
ls src
You should see middleware and routes listed inside the src folder.
Now create the two files that make up the minimal Express server. The app logic lives in src/app.js and the startup code lives in src/server.js. Keeping them separate lets your tests import the app without starting the server.
- In the Explorer sidebar, right-click the src folder and click New File.
- Name the file app.js and press Enter.
- Paste the following code into src/app.js:
require('dotenv').config();
const express = require('express');
const app = express();
// Parse incoming JSON request bodies
app.use(express.json());
// Health check endpoint to verify the server is running
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
What does this code do?
- require('dotenv').config() loads the .env variables into process.env so your secrets are accessible throughout the app.
- express.json() is middleware that parses JSON request bodies. Without it, req.body would be undefined.
- The /health endpoint returns a simple JSON response. You will use it to confirm the server is running.
- module.exports = app exports the app so server.js and your test files can import it separately.
- Save src/app.js with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/app.js.
ⓧ I'd like to double check the full code
Your src/app.js should look like this:
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
- Right-click the src folder in the Explorer sidebar again and click New File.
- Name the file server.js and press Enter.
- Paste the following code into src/server.js:
const app = require('./app');
const PORT = process.env.PORT || 3000;
// Start the server and log both the base URL and Swagger docs URL
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Swagger docs at http://localhost:${PORT}/api-docs`);
});
What does this code do?
This file imports the app from app.js and starts listening for requests. It reads the port from your .env file, falling back to 3000 if PORT is not set.
The Swagger docs URL is logged now even though Swagger is not configured yet. You will wire that up in Step 3.
- Save src/server.js with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/server.js.
ⓧ I'd like to double check the full code
Your src/server.js should look like this:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Swagger docs at http://localhost:${PORT}/api-docs`);
});
Before you run the server, what do you think you will see in the terminal when it starts? Take a guess.
- Start the server by running:
node src/server.js
You should see two lines printed in the terminal:
Server running on http://localhost:3000
Swagger docs at http://localhost:3000/api-docs
The server is running and listening for requests. Now verify the health endpoint works.
- Open a second terminal tab by pressing Ctrl+Shift+` in VS Code.
- Hit the health endpoint by running:
Invoke-RestMethod http://localhost:3000/health
You should see the response:
status
------
ok
Your Express server is running and responding to requests.
- Switch back to your first terminal tab (the one running the server).
- Stop the server by pressing Ctrl+C.
Server not starting or port already in use?
- If you see an error about the port being in use, another process is already listening on port 3000. Stop it by closing the other terminal, or change PORT in your .env file to 3001.
- If you see a module-not-found error, make sure you ran npm install earlier and that your terminal is inside the secure-notes-api folder.
- If Invoke-RestMethod hangs, check that you opened a new terminal tab for the request. The first tab is occupied by the running server.
Help me troubleshoot my Express server startup.
Your project is set up with all the libraries, secrets, and a running server. Next up, you will build the authentication system and notes CRUD so users can register, log in, and manage their own data.
Build the Auth System and Notes CRUD
Your project structure is ready and all dependencies are installed. Right now, if you added any routes, anyone could call them. There is no way to know who is making a request or whether they should have access.
This step solves that problem. You will build JWT-based authentication with short-lived access tokens, refresh token rotation, and bcrypt password hashing. Then you will create notes CRUD endpoints that only authenticated users can reach.
In this step, get ready to:
- Build auth middleware and a JWT token system.
- Create register, login, refresh, and logout routes.
- Build notes CRUD endpoints protected by authentication.
Create the user store, auth routes, and auth middleware
Every protected route needs a gatekeeper. Before building any routes, you will create the middleware that checks whether an incoming request carries a valid JWT.
- Create a new file called src/middleware/auth.js and add the following code:
const jwt = require('jsonwebtoken');
// Protects routes by verifying JWT access tokens
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
// Reject requests with no Bearer token
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.split(' ')[1];
try {
// Only accept HS256 to prevent algorithm confusion attacks
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
});
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authMiddleware;
What does this code do?
This middleware runs before any protected route handler. It looks for an Authorization header starting with Bearer , pulls out the token, and verifies it with jwt.verify.
The algorithms: ['HS256'] option is critical. Without it, an attacker could craft a token using a different algorithm and bypass verification. Whitelisting the algorithm locks that door.
- Save src/middleware/auth.js.
- Confirm the file was created by running:
ls src/middleware/
You should see auth.js listed in the output.
Don't see auth.js?
- Make sure you saved the file inside src/middleware/, not in the project root or src/ directly.
- Check that the src/middleware/ folder exists. If it does not, create it first.
Help me set up my auth middleware
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
});
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authMiddleware;
Now you will build the auth routes file. This file handles user registration, login, token refresh, and logout.
- Create a new file called src/routes/auth.js and add the following setup code:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { rateLimit } = require('express-rate-limit');
const router = express.Router();
// In-memory stores for users and active refresh tokens
const users = [];
const refreshTokens = [];
// Limit auth endpoints to 5 requests per 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
});
What does this code do?
The users array stores registered accounts in memory. The refreshTokens array tracks which refresh tokens are currently valid.
The authLimiter middleware throttles requests to auth routes. This prevents brute-force password attacks by capping login attempts at 5 per 15-minute window.
- Add the token generator functions below the rate limiter in src/routes/auth.js:
// Generate a short-lived access token (15 minutes)
function generateAccessToken(user) {
return jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '15m' }
);
}
// Generate a long-lived refresh token (7 days)
function generateRefreshToken(user) {
return jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_REFRESH_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' }
);
}
Why two tokens?
The access token expires after 15 minutes. If it is stolen, the attacker has a very short window to use it. The refresh token lasts 7 days and is only used to request a new access token.
This separation means the user stays logged in for days, but the actual credential flying around on every request has a 15-minute shelf life.
- Save src/routes/auth.js.
- Verify the file has no syntax errors by running:
node --check src/routes/auth.js
If there is no output, the syntax is valid. If you see a SyntaxError, double-check your brackets and commas.
- Add the register route below the token generators in src/routes/auth.js. Also add the module.exports line at the very bottom of the file:
// Register a new user with a bcrypt-hashed password
router.post('/register', authLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
// Check if username is already taken
const existingUser = users.find((u) => u.username === username);
if (existingUser) {
return res.status(400).json({ error: 'Username already exists' });
}
// Hash the password with 10 salt rounds before storing
const hashedPassword = await bcrypt.hash(password, 10);
const user = {
id: users.length + 1,
username,
password: hashedPassword,
};
users.push(user);
res.status(201).json({ message: 'User registered successfully', userId: user.id });
});
module.exports = router;
What does the register route do?
When a user submits a username and password, bcrypt.hash(password, 10) converts the plain-text password into a one-way hash with 10 salt rounds. The original password is never stored.
The module.exports = router at the bottom makes this file importable. You will add more routes above that line next.
- Save src/routes/auth.js.
Time to mount the auth routes so you can test registration. You need to update src/app.js to import and use the new routes.
- Replace the contents of src/app.js with the following code:
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const app = express();
// Parse JSON bodies and cookies
app.use(express.json());
app.use(cookieParser());
// Mount auth routes at /api/auth
app.use('/api/auth', authRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
What changed in app.js?
You added cookie-parser middleware for handling cookies in later steps. You also imported the auth routes and mounted them at /api/auth. Every route in auth.js now lives under that prefix.
- Save src/app.js.
- Start the server by running:
node src/server.js
You should see Server running on http://localhost:3000 in the output. You will also see a line about Swagger docs. Ignore that for now.
Keep the server running
The server occupies this terminal window. You need a second PowerShell window to send test requests. Keep this terminal open.
- Press the Windows key, type PowerShell, and press Enter to open a second window.
- Navigate to your project folder in the new window by running:
cd ~/Desktop/secure-notes-api
- Test the register endpoint by running:
Invoke-RestMethod -Uri http://localhost:3000/api/auth/register -Method POST -Body '{"username":"testuser","password":"securepass123"}' -ContentType 'application/json'
You should see a response like this:
message userId
------- ------
User registered successfully 1
Seeing an error instead?
- If you see a connection error, make sure the server is still running in your first terminal window.
- If you see a 400 error about missing fields, check that you included -ContentType 'application/json' in the command. Without it, PowerShell sends form data instead of JSON.
- If you see a module import error in the server terminal, check that src/app.js has the correct path ./routes/auth in the require call.
Help me debug my register endpoint
Registration works. Now you will add the login, refresh, and logout routes to complete the auth system.
- In src/routes/auth.js, find the line that says module.exports = router;. Add the following login route above it:
// Log in and issue access + refresh tokens
router.post('/login', authLimiter, async (req, res) => {
const { username, password } = req.body;
const user = users.find((u) => u.username === username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare submitted password against stored bcrypt hash
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store the refresh token for later validation
refreshTokens.push({ token: refreshToken, userId: user.id });
res.json({ accessToken, refreshToken });
});
What does the login route do?
The route finds the user by username, then uses bcrypt.compare to check the submitted password against the stored hash. If both checks pass, it generates a fresh access token and refresh token.
Notice the error messages say Invalid credentials for both wrong username and wrong password. This prevents attackers from learning which usernames exist.
- Still in src/routes/auth.js, add the refresh route above module.exports = router;:
router.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const storedIndex = refreshTokens.findIndex((t) => t.token === refreshToken);
if (storedIndex === -1) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256'],
});
refreshTokens.splice(storedIndex, 1);
const user = users.find((u) => u.id === decoded.id);
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
refreshTokens.push({ token: newRefreshToken, userId: user.id });
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (error) {
refreshTokens.splice(storedIndex, 1);
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
What is refresh token rotation?
When a client uses a refresh token, the server deletes the old one (splice) and issues a brand new pair. This means each refresh token can only be used once.
If an attacker steals a refresh token and uses it, the legitimate user's next refresh fails. This signals a compromise and limits the blast radius.
- Save src/routes/auth.js.
- Switch to your server terminal and press Ctrl+C to stop the server.
- Restart the server by running:
node src/server.js
- Switch back to your second PowerShell window and test login by running:
$login = Invoke-RestMethod -Uri http://localhost:3000/api/auth/login -Method POST -Body '{"username":"testuser","password":"securepass123"}' -ContentType 'application/json'
$login
You should see accessToken and refreshToken values in the response. Both are long encoded strings.
Getting a 401 error?
- The in-memory data store resets every time you restart the server. You need to register the user again before logging in. Run the register command from earlier first.
- Double-check that the username and password in your login command match exactly what you used to register.
Help me debug my login endpoint
- Add the logout route above module.exports = router; in src/routes/auth.js:
// Remove the refresh token so it can no longer be used
router.post('/logout', (req, res) => {
const { refreshToken } = req.body;
const index = refreshTokens.findIndex((t) => t.token === refreshToken);
if (index !== -1) {
refreshTokens.splice(index, 1);
}
res.json({ message: 'Logged out successfully' });
});
What does logout do?
Logout removes the refresh token from the store. The access token is still valid until it expires (up to 15 minutes), but the user can no longer get a new one without logging in again.
- Save src/routes/auth.js.
✔️ Awesome, I've got everything!
Your src/routes/auth.js is complete for this step. Make sure you have saved the file.
ⓧ I'd like to double check the full code
Your src/routes/auth.js should have the following sections in order from top to bottom. Scroll up and confirm each chunk is present:
- Imports: express, jsonwebtoken, bcrypt, express-rate-limit.
- Router, users array, and refreshTokens array.
- The authLimiter rate limit configuration.
- The generateAccessToken and generateRefreshToken helper functions.
- The /register route.
- The /login route.
- The /refresh route.
- The /logout route.
- The module.exports = router; line at the very bottom.
Build the notes CRUD routes
Users can register and log in. Now they need something to protect. You will build a notes system where each user can only see and manage their own notes.
- Create a new file called src/routes/notes.js and add the following code:
const express = require('express');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
// In-memory notes store
const notes = [];
let noteIdCounter = 1;
// Get all notes belonging to the authenticated user
router.get('/', authMiddleware, (req, res) => {
const userNotes = notes.filter((n) => n.userId === req.user.id);
res.json(userNotes);
});
What does this code do?
The authMiddleware you built earlier is passed as the second argument to router.get. Express runs it before the route handler. If the token is invalid, the request is rejected before the handler ever runs.
The GET route filters notes by req.user.id. This value comes from the decoded JWT payload that the middleware attached. Each user only sees their own notes.
- Add the POST route below the GET route in src/routes/notes.js:
// Create a new note for the authenticated user
router.post('/', authMiddleware, (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Title and content are required' });
}
const note = {
id: noteIdCounter++,
userId: req.user.id,
title,
content,
createdAt: new Date().toISOString(),
};
notes.push(note);
res.status(201).json(note);
});
What does the POST route do?
Each note gets an auto-incrementing id and is linked to the authenticated user via userId: req.user.id. A timestamp marks when it was created.
- Save src/routes/notes.js.
Time to mount the notes routes so you can test them. You need to add notes to src/app.js.
- In src/app.js, find the line:
const authRoutes = require('./routes/auth');
- Add this import line directly below it:
const notesRoutes = require('./routes/notes');
- Find the line:
app.use('/api/auth', authRoutes);
- Add this mount line directly below it:
app.use('/api/notes', notesRoutes);
- Save src/app.js.
- Switch to your server terminal, press Ctrl+C to stop the server, and restart it:
node src/server.js
- In your second PowerShell window, register a user and log in (data resets on restart):
Invoke-RestMethod -Uri http://localhost:3000/api/auth/register -Method POST -Body '{"username":"noteuser","password":"pass123"}' -ContentType 'application/json'
$login = Invoke-RestMethod -Uri http://localhost:3000/api/auth/login -Method POST -Body '{"username":"noteuser","password":"pass123"}' -ContentType 'application/json'
$token = $login.accessToken
- Create a note using the access token by running:
Invoke-RestMethod -Uri http://localhost:3000/api/notes -Method POST -Headers @{Authorization="Bearer $token"} -Body '{"title":"My First Note","content":"Hello from the API!"}' -ContentType 'application/json'
You should see the note returned with an id, title, content, and createdAt timestamp.
Getting a 401 Unauthorized?
- Make sure you ran the register and login commands in the block above first. The $token variable is set from the login response.
- Check that the -Headers value has no extra spaces. It should be @{Authorization="Bearer $token"} with no space between Bearer and $token except the single space after the word Bearer.
Help me debug my notes endpoint
Now add the PUT and DELETE routes to complete the CRUD operations.
- Add the following code below the POST route in src/routes/notes.js. Also add the module.exports lines at the very bottom of the file:
// Update a note (only if it belongs to the authenticated user)
router.put('/:id', authMiddleware, (req, res) => {
const noteId = parseInt(req.params.id);
const note = notes.find((n) => n.id === noteId && n.userId === req.user.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
if (req.body.title) note.title = req.body.title;
if (req.body.content) note.content = req.body.content;
res.json(note);
});
// Delete a note (only if it belongs to the authenticated user)
router.delete('/:id', authMiddleware, (req, res) => {
const noteId = parseInt(req.params.id);
const index = notes.findIndex((n) => n.id === noteId && n.userId === req.user.id);
if (index === -1) {
return res.status(404).json({ error: 'Note not found' });
}
notes.splice(index, 1);
res.json({ message: 'Note deleted successfully' });
});
module.exports = router;
module.exports.notes = notes;
What do the PUT and DELETE routes do?
Both routes check n.userId === req.user.id when looking up a note. This means a user can never modify or delete another user's notes, even if they guess the note ID.
The module.exports.notes = notes line exports the notes array separately. This will be used later for admin functionality.
- Save src/routes/notes.js.
✔️ Awesome, I've got everything!
Your src/routes/notes.js is complete for this step. Make sure you have saved the file.
ⓧ I'd like to double check the full code
Your src/routes/notes.js should have these sections in order. Scroll up and confirm each chunk is present:
- Imports: express and authMiddleware.
- Router, notes array, and noteIdCounter.
- GET / route (returns authenticated user's notes).
- POST / route (creates a new note).
- PUT /:id route (updates a note).
- DELETE /:id route (deletes a note).
- Both module.exports lines at the bottom.
✔️ My app.js looks right!
Make sure you have saved src/app.js with both route mounts.
ⓧ I'd like to double check app.js
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const notesRoutes = require('./routes/notes');
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRoutes);
app.use('/api/notes', notesRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
Wire up routes and test manually
Both the auth system and notes CRUD are built. Time to run the full flow end to end and confirm everything works together.
- Switch to your server terminal, press Ctrl+C to stop the server, and restart it:
node src/server.js
Before you run the full test, think about what you expect to happen. If you register, log in, create a note with the token, and then GET all notes, what should come back?
- In your second PowerShell window, run the complete flow by pasting each command one at a time:
Invoke-RestMethod -Uri http://localhost:3000/api/auth/register -Method POST -Body '{"username":"finaltest","password":"mypassword"}' -ContentType 'application/json'
$login = Invoke-RestMethod -Uri http://localhost:3000/api/auth/login -Method POST -Body '{"username":"finaltest","password":"mypassword"}' -ContentType 'application/json'
$token = $login.accessToken
Invoke-RestMethod -Uri http://localhost:3000/api/notes -Method POST -Headers @{Authorization="Bearer $token"} -Body '{"title":"Meeting Notes","content":"Discuss API security roadmap"}' -ContentType 'application/json'
Invoke-RestMethod -Uri http://localhost:3000/api/notes -Method GET -Headers @{Authorization="Bearer $token"}
The final GET should return an array containing the note you just created. You should see the id, title, content, and createdAt fields.
Not seeing your note?
- Run each command one at a time, not all at once. Check the output of each step before moving to the next.
- If register returns a 429 error, you hit the rate limit. Wait 15 minutes or restart the server (which resets the limit counter).
- Make sure the $token variable is set. Run $token by itself to check its value. It should be a long string starting with eyJ.
Help me debug the full auth and notes flow
Your API now has a full authentication system and protected note endpoints. Next up, you will add interactive Swagger documentation so you can explore and test every endpoint from the browser.
Add Swagger Documentation with Bearer Auth
Your API works, but right now the only way to test it is by manually crafting Invoke-RestMethod commands in PowerShell. Every time you want to try an endpoint, you need to remember the path, the method, and the exact JSON body.
Swagger solves this by generating an interactive documentation page directly from your code. You write OpenAPI annotations above each route handler, and Swagger builds a browser-based UI where you can fill in fields, click a button, and see the response.
In this step, get ready to:
- Configure swagger-jsdoc with an OpenAPI 3.0 spec and a bearerAuth security scheme.
- Add @swagger annotations to every auth and notes route handler.
- Mount Swagger UI and test the full auth and CRUD flow from the browser.
Set up swagger-jsdoc and mount Swagger UI
The swagger-jsdoc library scans your route files for @swagger comment blocks and assembles them into a single OpenAPI specification. The swagger-ui-express library then serves that spec as an interactive web page.
- In src/app.js, find the line that says const cookieParser = require('cookie-parser'); and add these three imports directly below it:
const helmet = require('helmet');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
Why import Helmet now?
You will configure Helmet for security headers in the next step. Importing it now keeps all the imports together so you only touch the top of the file once.
- Next, find the line app.use(cookieParser()); and add the following swagger configuration block below it:
// Define the OpenAPI 3.0 spec metadata
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'Secure Notes API',
version: '1.0.0',
description: 'A REST API with JWT authentication, Swagger docs, and XSS protection',
},
servers: [{ url: 'http://localhost:3000' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
},
// Tell swagger-jsdoc which files contain @swagger annotations
apis: ['./src/routes/*.js'],
};
// Generate the OpenAPI spec from annotations
const swaggerSpec = swaggerJsdoc(swaggerOptions);
// Serve Swagger UI at /api-docs
app.use(
'/api-docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec, { explorer: true })
);
What does this configuration do?
The definition object tells OpenAPI your API's name, version, and base URL. The securitySchemes section registers a scheme called bearerAuth that expects a JWT in the Authorization header.
The apis array tells swagger-jsdoc which files to scan for @swagger comments. The glob ./src/routes/*.js covers every route file in your project.
- Save src/app.js.
- If your server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
- Restart the server by running:
node src/server.js
- Open your browser and navigate to http://localhost:3000/api-docs.
You should see the Swagger UI page load with the title "Secure Notes API" and an Authorize button in the top right. The page will not show any endpoints yet because you have not added @swagger annotations to your route files.
Seeing a blank page or an error?
- Make sure the app.use('/api-docs', ...) line is placed BEFORE the app.use('/api/auth', authRoutes) line in src/app.js.
- Confirm you saved the file and restarted the server after saving.
- Check that the apis path in your swagger options uses ./src/routes/*.js (starting from the project root, not a relative path from src/).
- help me debug my Swagger UI page
Add @swagger annotations to your route handlers
Each @swagger block is a JSDoc comment placed directly above a route handler. It describes the endpoint's path, HTTP method, request body, and responses in YAML syntax.
- In src/routes/auth.js, find the line router.post('/register', authLimiter, async (req, res) => { and add this annotation block directly above it:
/**
* @swagger
* /api/auth/register:
* post:
* summary: Register a new user
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* responses:
* 201:
* description: User registered successfully
* 400:
* description: Username already exists
*/
What does this annotation do?
- The @swagger tag tells swagger-jsdoc to parse this comment block as an OpenAPI path definition.
- The tags: [Auth] groups this endpoint under an "Auth" section in the Swagger UI.
- The requestBody section tells Swagger what JSON fields the endpoint expects, so it can pre-fill a form for you.
- The responses section documents the possible HTTP status codes.
- Still in src/routes/auth.js, find the line router.post('/login', authLimiter, async (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/auth/login:
* post:
* summary: Log in and receive tokens
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* responses:
* 200:
* description: Login successful, returns access and refresh tokens
* 401:
* description: Invalid credentials
*/
- Save src/routes/auth.js, restart the server with Ctrl+C then node src/server.js, and refresh http://localhost:3000/api-docs in your browser.
You should now see an "Auth" tag with POST /api/auth/register and POST /api/auth/login listed underneath it.
- Now find the line router.post('/refresh', (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/auth/refresh:
* post:
* summary: Refresh an access token
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - refreshToken
* properties:
* refreshToken:
* type: string
* responses:
* 200:
* description: New access and refresh tokens
* 401:
* description: Invalid refresh token
*/
- Find the line router.post('/logout', (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/auth/logout:
* post:
* summary: Log out and invalidate refresh token
* tags: [Auth]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - refreshToken
* properties:
* refreshToken:
* type: string
* responses:
* 200:
* description: Logged out successfully
*/
- Save src/routes/auth.js, restart the server, and refresh the Swagger UI page.
You should now see all four auth endpoints listed under the "Auth" tag: register, login, refresh, and logout.
✔️ Awesome, I've got everything!
Great. Make sure you saved src/routes/auth.js before moving on.
ⓧ I'd like to double check the full code
Your src/routes/auth.js file is over 30 lines, so confirm these blocks are present in order by scrolling through:
- Imports (express, jsonwebtoken, bcrypt, express-rate-limit) and the users / refreshTokens arrays.
- The authLimiter rate limiter config.
- The generateAccessToken and generateRefreshToken helper functions.
- A @swagger annotation directly above each of the four route handlers: register, login, refresh, logout.
- The module.exports = router; line at the bottom.
Now add the same kind of annotations to your notes routes. The key difference is that notes endpoints are protected, so each annotation needs a security field that references the bearerAuth scheme you defined in the swagger config.
- In src/routes/notes.js, find the line router.get('/', authMiddleware, (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/notes:
* get:
* summary: Get all notes for the authenticated user
* tags: [Notes]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of notes
* 401:
* description: Unauthorized
*/
What does the security field do?
The security: [{ bearerAuth: [] }] line tells Swagger that this endpoint requires a valid JWT. In the Swagger UI, endpoints with this field show a lock icon. When you click Authorize and paste a token, Swagger automatically includes it in the Authorization header for those endpoints.
- Find the line router.post('/', authMiddleware, (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/notes:
* post:
* summary: Create a new note
* tags: [Notes]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - title
* - content
* properties:
* title:
* type: string
* content:
* type: string
* responses:
* 201:
* description: Note created
* 401:
* description: Unauthorized
*/
- Save src/routes/notes.js, restart the server, and refresh the Swagger UI page.
You should now see a "Notes" tag with GET /api/notes and POST /api/notes listed. Both should have a lock icon next to them.
- Find the line router.put('/:id', authMiddleware, (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/notes/{id}:
* put:
* summary: Update a note
* tags: [Notes]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* content:
* type: string
* responses:
* 200:
* description: Note updated
* 404:
* description: Note not found
*/
- Find the line router.delete('/:id', authMiddleware, (req, res) => { and add this annotation directly above it:
/**
* @swagger
* /api/notes/{id}:
* delete:
* summary: Delete a note
* tags: [Notes]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Note deleted
* 404:
* description: Note not found
*/
- Save src/routes/notes.js, restart the server, and refresh the Swagger UI page.
You should now see all four notes endpoints under the "Notes" tag, each with a lock icon.
Seeing "No operations defined in spec!"?
- Double-check that each @swagger comment starts with /** (two asterisks, not one). A regular /* comment will be ignored by swagger-jsdoc.
- Make sure the apis path in your swagger options is './src/routes/*.js'. This path is relative to the project root where you run node src/server.js.
- Verify the YAML indentation inside each annotation block uses exactly 2 spaces per level.
- help me fix my Swagger annotations
✔️ Awesome, I've got everything!
Great. Make sure you saved src/routes/notes.js before moving on.
ⓧ I'd like to double check the full code
Your src/routes/notes.js file is over 30 lines, so confirm these blocks are present in order by scrolling through:
- Imports (express, authMiddleware) and the notes array / noteIdCounter.
- A @swagger annotation directly above each of the four route handlers: GET /, POST /, PUT /:id, DELETE /:id.
- Every notes annotation includes security: - bearerAuth: [].
- The PUT and DELETE annotations include a parameters section for the path id.
- The module.exports = router; and module.exports.notes = notes; lines at the bottom.
Test the full flow through Swagger UI
Your Swagger UI now lists every endpoint. Time to test the full authentication and CRUD flow without leaving the browser.
- In the Swagger UI page, expand POST /api/auth/register and click Try it out.
- Replace the JSON body with:
{
"username": "swaggeruser",
"password": "test1234"
}
- Click Execute. You should see a 201 response with User registered successfully.
- Expand POST /api/auth/login, click Try it out, and enter the same username and password.
- Click Execute. Copy the accessToken value from the response body.
- Click the Authorize button at the top of the page.
- Paste the access token into the Value field and click Authorize, then Close.
What does clicking Authorize do?
Swagger stores your token in memory and automatically adds an Authorization: Bearer <token> header to every request you make through the UI. This is the same header your authMiddleware checks on every protected route.
Before you test the notes endpoint, what do you expect to happen when you POST a note through Swagger UI with the token authorized?
- Expand POST /api/notes, click Try it out, and enter:
{
"title": "My Swagger Note",
"content": "Created through the interactive docs"
}
- Click Execute.
You should see a 201 response with your note's id, title, content, and createdAt fields.
- Expand GET /api/notes, click Try it out, and click Execute.
You should see a 200 response with an array containing the note you just created.
Getting a 401 Unauthorized?
- Your access token expires after 15 minutes. If you have been working through the annotations for a while, log in again and re-authorize with a fresh token.
- Make sure you clicked Authorize and pasted the token without the surrounding quotes.
- help me fix 401 errors in Swagger UI
✔️ Awesome, I've got everything!
Great. Make sure you saved both src/routes/auth.js and src/routes/notes.js before moving on.
ⓧ I'd like to double check the full code
Your src/app.js is over 30 lines. Confirm these sections are present in order:
- Imports at the top: dotenv, express, cookie-parser, helmet, swagger-jsdoc, swagger-ui-express, authRoutes, notesRoutes.
- Middleware: express.json() and cookieParser().
- The swaggerOptions object with openapi: '3.0.0' and securitySchemes.
- The swaggerSpec generation and /api-docs mount.
- Route mounts for /api/auth and /api/notes.
- The /health endpoint and module.exports.
Your API now has a complete set of interactive docs. Anyone on your team can open /api-docs, test every endpoint, and see exactly what fields each route expects. Next up, you will deliberately inject an XSS payload into a note and watch it execute, then lock it down with security headers and input sanitization.
Exploit, Then Fix XSS Vulnerabilities
You have a working API with Swagger docs, but there is a dangerous gap hiding underneath. Right now, the API stores whatever content users submit in their notes, including malicious HTML and JavaScript.
If any frontend renders that content as raw HTML, an attacker can inject scripts that steal session cookies or hijack accounts. In this step, you will build a route that exposes this vulnerability, watch it execute, then lock it down with layered defenses.
In this step, get ready to:
- Create a render route that serves note content as raw HTML and exploit it with a malicious XSS payload.
- Add Helmet security headers and DOMPurify input sanitization to neutralize XSS attacks.
- Verify that the same malicious payload is stripped and security headers are present in every response.
Demonstrate the XSS vulnerability
Your notes API returns data as JSON, which is safe. But imagine a page that renders a note's content directly as HTML. That is exactly the kind of surface a real XSS attack targets.
- If your server is still running from the previous step, press Ctrl+C in PowerShell to stop it.
- In VS Code, open src/routes/notes.js.
- Scroll to the bottom and find the line module.exports = router;.
- Add this Swagger annotation directly above that line:
/**
* @swagger
* /api/notes/{id}/render:
* get:
* summary: Render a note as HTML (demonstrates XSS vector)
* tags: [Notes]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Note rendered as HTML
* 404:
* description: Note not found
*/
What does this annotation do?
This @swagger JSDoc block tells swagger-jsdoc to document a new GET /api/notes/{id}/render endpoint. It will appear in Swagger UI alongside your other routes.
- Add the render route handler directly below the annotation you just pasted, still above module.exports:
router.get('/:id/render', authMiddleware, (req, res) => {
const noteId = parseInt(req.params.id);
const note = notes.find((n) => n.id === noteId && n.userId === req.user.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
// Return the note content as raw HTML
res.type('html').send(`
<html>
<head><title>${note.title}</title></head>
<body>
<h1>${note.title}</h1>
<div>${note.content}</div>
</body>
</html>
`);
});
What does this route do?
This route takes a note's title and content and embeds them directly into an HTML page with res.type('html').send(). Whatever the user stored in the note gets served as raw HTML to the browser. No escaping, no filtering.
- Save src/routes/notes.js.
- Start the server by running:
node src/server.js
You should see the server start on port 3000. Now open a second PowerShell terminal so you can send requests while the server keeps running.
- Register a user and log in to get an access token by running these commands in the second PowerShell terminal:
# Register a test user
Invoke-RestMethod -Uri "http://localhost:3000/api/auth/register" -Method Post -ContentType "application/json" -Body '{"username":"testuser","password":"testpass123"}'
# Login and capture the access token
$login = Invoke-RestMethod -Uri "http://localhost:3000/api/auth/login" -Method Post -ContentType "application/json" -Body '{"username":"testuser","password":"testpass123"}'
$token = $login.accessToken
You should see a success message for registration and an accessToken in the login response.
- Create a note with a malicious XSS payload in its content by running:
# Create a note containing a malicious HTML payload
$xssBody = @{title="XSS Test"; content='<img src=x onerror="alert(''xss'')">'} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:3000/api/notes" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $token"} -Body $xssBody
What is this payload?
The content <img src=x onerror="alert('xss')"> is a classic XSS attack vector. The browser tries to load an image from x, which fails. The onerror handler then executes arbitrary JavaScript.
The response shows the note was created with an id of 1. Look at the content field. The API stored the malicious payload exactly as submitted.
Before you fetch the rendered note, what do you think the HTML response will look like? Will the browser treat the onerror attribute as executable code or as plain text?
- Fetch the rendered note, save it as an HTML file, and open it in your browser by running:
# Fetch the rendered HTML and save it to a file
$rendered = Invoke-WebRequest -Uri "http://localhost:3000/api/notes/1/render" -Headers @{Authorization="Bearer $token"}
$rendered.Content | Out-File -FilePath "xss-demo.html" -Encoding UTF8
# Open the file in your default browser
Start-Process "xss-demo.html"
Your browser opens the HTML file. A JavaScript alert dialog pops up with the text xss. The malicious script executed successfully.
This is the vulnerability in action
Your API stored the malicious HTML exactly as the user submitted it. The render route embedded it directly into an HTML page. The browser executed the onerror handler as real JavaScript.
In a real application, an attacker could replace alert('xss') with code that steals session cookies, redirects users to phishing pages, or makes API calls on behalf of the victim.
Don't see the alert dialog?
Some browsers block alerts from local HTML files. You can check the raw HTML output instead by running $rendered.Content in your second PowerShell terminal. If the output contains onerror in the HTML, the vulnerability exists regardless of whether the alert fired locally.
If you see a 401 error, your access token may have expired. Run the login command again to get a fresh token.
Help me debug why the XSS demo is not working.
Add Helmet for security headers and sanitize input with DOMPurify
You just proved the API is vulnerable. Fixing this requires two layers of defense. Helmet adds HTTP security headers that tell the browser to block inline scripts. DOMPurify strips dangerous HTML attributes before the content is ever stored.
Why two layers instead of one?
Defense in depth means no single layer is trusted to catch everything. DOMPurify sanitizes input at write time, removing dangerous attributes like onerror before they reach storage. Helmet's Content-Security-Policy header tells the browser to block inline scripts even if a sanitization bypass slips through.
If DOMPurify misses a new attack vector, the CSP header is still there as a backstop. Both layers have to fail for an attack to succeed.
- Switch back to the first PowerShell terminal and press Ctrl+C to stop the server.
- In VS Code, open src/app.js.
- Find the line const app = express();.
- Add the following code directly below it, above the line app.use(express.json());:
// Check if running in development mode
const isDevelopment = process.env.NODE_ENV !== 'production';
// Add security headers with Helmet as the first middleware
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
'upgrade-insecure-requests': isDevelopment ? null : [],
},
},
})
);
What does this configuration do?
Helmet sets multiple HTTP security headers in every response. The contentSecurityPolicy directives tell the browser to only load scripts from the same origin ('self'). Inline event handlers like onerror are blocked by default.
The 'unsafe-inline' on styleSrc allows Swagger UI's inline styles to render properly. The upgrade-insecure-requests directive is disabled in development because it would force http://localhost to upgrade to HTTPS, breaking local testing.
- Save src/app.js.
- Start the server again in your first PowerShell terminal by running:
node src/server.js
- In your second PowerShell terminal, check the response headers by running:
# Check the security headers Helmet adds
$response = Invoke-WebRequest -Uri "http://localhost:3000/health"
$response.Headers["Content-Security-Policy"]
You should see a Content-Security-Policy header value containing default-src 'self' and script-src 'self'. Helmet is working.
Not seeing the Content-Security-Policy header?
Make sure the app.use(helmet({...})) call is placed above app.use(express.json()) in src/app.js. Helmet must be the first middleware so it applies headers to all responses.
Help me figure out why Helmet headers are not appearing in my responses.
Headers are locked down. Now let's add the second layer: input sanitization that strips dangerous HTML before the content is stored.
- Switch back to your first PowerShell terminal and press Ctrl+C to stop the server.
- Create a new file at src/middleware/sanitize.js and add the following code:
const DOMPurify = require('isomorphic-dompurify');
// Middleware that sanitizes title and content before they reach route handlers
function sanitizeInput(req, res, next) {
if (req.body) {
if (req.body.title) {
req.body.title = DOMPurify.sanitize(req.body.title);
}
if (req.body.content) {
req.body.content = DOMPurify.sanitize(req.body.content);
}
}
next();
}
module.exports = sanitizeInput;
What does DOMPurify do?
DOMPurify parses HTML and removes anything that could execute code. It keeps safe tags like <img> but strips dangerous attributes like onerror and onclick. The isomorphic-dompurify package bundles a DOM environment so DOMPurify works on the server, not just in browsers.
- Save src/middleware/sanitize.js.
Now you need to wire this middleware into the notes routes so it runs on every request that accepts user-written content.
- Switch back to src/routes/notes.js in VS Code.
- Find this import near the top of the file:
const authMiddleware = require('../middleware/auth');
- Add this import directly below it:
const sanitizeInput = require('../middleware/sanitize');
- Now find the POST route handler line:
router.post('/', authMiddleware, (req, res) => {
- Replace it with this line, adding sanitizeInput between authMiddleware and the handler:
router.post('/', authMiddleware, sanitizeInput, (req, res) => {
- Find the PUT route handler line:
router.put('/:id', authMiddleware, (req, res) => {
- Replace it with this line, adding sanitizeInput the same way:
router.put('/:id', authMiddleware, sanitizeInput, (req, res) => {
- Save src/routes/notes.js.
✔️ Awesome, I've got everything!
Make sure you have saved src/app.js, src/middleware/sanitize.js, and src/routes/notes.js.
ⓧ I'd like to double check the full code
Here is the complete src/middleware/sanitize.js file:
const DOMPurify = require('isomorphic-dompurify');
function sanitizeInput(req, res, next) {
if (req.body) {
if (req.body.title) {
req.body.title = DOMPurify.sanitize(req.body.title);
}
if (req.body.content) {
req.body.content = DOMPurify.sanitize(req.body.content);
}
}
next();
}
module.exports = sanitizeInput;
For src/app.js, confirm the following appears between const app = express(); and app.use(express.json());:
- const isDevelopment = process.env.NODE_ENV !== 'production'; on its own line.
- app.use(helmet({...})) with contentSecurityPolicy directives matching the code block you added earlier.
For src/routes/notes.js, scroll through and confirm:
- const sanitizeInput = require('../middleware/sanitize'); is present near the top, below the authMiddleware import.
- The POST route reads router.post('/', authMiddleware, sanitizeInput, (req, res) => {.
- The PUT route reads router.put('/:id', authMiddleware, sanitizeInput, (req, res) => {.
- The render route (router.get('/:id/render', ...)) with its Swagger annotation is present above module.exports.
Verify the fix
Both defense layers are in place. Time to throw the same attack at your API and see what happens.
- Start the server in your first PowerShell terminal by running:
node src/server.js
- In your second PowerShell terminal, register a fresh user, log in, and create the same malicious note by running:
# Register and login with a new user
Invoke-RestMethod -Uri "http://localhost:3000/api/auth/register" -Method Post -ContentType "application/json" -Body '{"username":"testuser2","password":"testpass123"}'
$login = Invoke-RestMethod -Uri "http://localhost:3000/api/auth/login" -Method Post -ContentType "application/json" -Body '{"username":"testuser2","password":"testpass123"}'
$token = $login.accessToken
- Submit the same XSS payload you used earlier by running:
# Create a note with the same malicious payload
$xssBody = @{title="XSS Test"; content='<img src=x onerror="alert(''xss'')">'} | ConvertTo-Json
Invoke-RestMethod -Uri "http://localhost:3000/api/notes" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $token"} -Body $xssBody
Do you think the response will still contain the onerror attribute, or will DOMPurify have stripped it?
Look at the content field in the response. Instead of <img src=x onerror="alert('xss')">, you should see <img src="x">. DOMPurify stripped the onerror attribute and properly quoted the src value. The malicious JavaScript is gone before it ever reaches storage.
- Verify the render route is also safe by saving and opening the rendered HTML:
# Fetch the rendered note and open it
$rendered = Invoke-WebRequest -Uri "http://localhost:3000/api/notes/1/render" -Headers @{Authorization="Bearer $token"}
$rendered.Content | Out-File -FilePath "xss-fixed.html" -Encoding UTF8
Start-Process "xss-fixed.html"
This time, no alert dialog appears. The page shows a broken image icon (since x is not a valid image source) but no JavaScript executes.
- Confirm the Helmet security headers are present by running:
# Check all security headers
$response = Invoke-WebRequest -Uri "http://localhost:3000/health"
$response.Headers["Content-Security-Policy"]
$response.Headers["X-Content-Type-Options"]
You should see the Content-Security-Policy header with your CSP directives and X-Content-Type-Options set to nosniff. Both defense layers are active.
Still seeing the onerror attribute in the response?
Make sure sanitizeInput appears between authMiddleware and the (req, res) => handler function in your POST route. If it is placed after the handler, it will never run.
Also confirm the import at the top of src/routes/notes.js reads require('../middleware/sanitize'), matching the file path exactly.
Help me debug why DOMPurify is not sanitizing my note content.
Your API now has real security teeth. Malicious content gets sanitized before storage, and even if a bypass slips through, the browser's CSP blocks inline scripts. Next up, you will write automated tests that prove these defenses work and wire them into a CI pipeline that runs on every push.
Write Jest Tests and Set Up GitHub Actions CI
Your API now blocks malicious input and returns security headers with every response. But every check you ran so far was manual.
Manual testing does not scale. In this step, you will write Jest tests that prove your auth flows and XSS sanitization work automatically. Then you will create a GitHub Actions CI/CD pipeline that runs those tests on every push.
In this step, get ready to:
- Write Jest tests covering authentication flows, notes CRUD, and XSS sanitization.
- Run the test suite locally and see all tests pass.
- Create a GitHub Actions CI workflow that runs tests automatically on every push.
Write Jest tests for auth endpoints
Each test sends an HTTP request to your app using supertest and checks the response status and body. You do not need a running server because supertest imports the Express app directly.
- Create a new file called tests/auth.test.js and paste the following code:
const request = require('supertest');
const app = require('../src/app');
describe('Auth Endpoints', () => {
// Test that a new user can register successfully
it('should register a new user', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ username: 'testuser', password: 'testpass123' });
expect(res.statusCode).toBe(201);
expect(res.body.message).toBe('User registered successfully');
});
// Test that registering with an existing username fails
it('should reject duplicate registration', async () => {
await request(app)
.post('/api/auth/register')
.send({ username: 'dupeuser', password: 'testpass123' });
const res = await request(app)
.post('/api/auth/register')
.send({ username: 'dupeuser', password: 'testpass123' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe('Username already exists');
});
What does this code do?
The first test sends a POST to /api/auth/register with a username and password, then checks for a 201 status and the success message.
The second test registers a user, then tries the same username again. It expects a 400 because the API rejects duplicates.
- Save tests/auth.test.js.
- Add the login and invalid credentials tests directly below the duplicate registration test by pasting this code:
// Test that login returns both access and refresh tokens
it('should login and return tokens', async () => {
await request(app)
.post('/api/auth/register')
.send({ username: 'loginuser', password: 'testpass123' });
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'loginuser', password: 'testpass123' });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('accessToken');
expect(res.body).toHaveProperty('refreshToken');
});
// Test that wrong credentials return 401
it('should reject invalid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'nonexistent', password: 'wrong' });
expect(res.statusCode).toBe(401);
});
What does this code do?
The login test registers a user first, then logs in and verifies that both accessToken and refreshToken are present in the response.
The invalid credentials test sends a login request with a username that does not exist. It expects a 401 status.
- Save tests/auth.test.js.
- Run your first four auth tests by running this command:
npm test
You should see 4 tests passing with green output. Two tests are not yet connected to a closing brace, but Jest still runs the ones it can find.
Tests failing or not found?
Make sure the file is saved as tests/auth.test.js (not test/ or src/tests/). Jest looks for files matching *.test.js by default.
Check that your .env file still has JWT_SECRET and JWT_REFRESH_SECRET values. The auth routes need these to sign tokens.
Help me debug my failing Jest tests
- Add the refresh token rotation test and the closing brace below the invalid credentials test by pasting this code:
// Test that refresh endpoint rotates the refresh token
it('should refresh tokens and rotate the refresh token', async () => {
await request(app)
.post('/api/auth/register')
.send({ username: 'refreshuser', password: 'testpass123' });
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: 'refreshuser', password: 'testpass123' });
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: loginRes.body.refreshToken });
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('accessToken');
expect(res.body).toHaveProperty('refreshToken');
// The new refresh token must be different from the original
expect(res.body.refreshToken).not.toBe(loginRes.body.refreshToken);
});
});
Why test token rotation?
Refresh token rotation is a critical security feature. If the same refresh token could be reused forever, a stolen token would give an attacker permanent access. This test proves that each refresh call invalidates the old token and issues a new one.
- Save tests/auth.test.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved tests/auth.test.js before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm these three chunks are present in order in your tests/auth.test.js:
- Chunk 1: Imports, describe block opener, register test, and duplicate registration test.
- Chunk 2: Login test and invalid credentials test.
- Chunk 3: Refresh token rotation test and closing });.
Write Jest tests for notes endpoints
The notes tests need a valid JWT to call protected endpoints. A helper function handles registration and login so each test starts with a fresh token.
- Create a new file called tests/notes.test.js and paste the following code:
const request = require('supertest');
const app = require('../src/app');
// Helper to register and login, returning a valid access token
async function getAuthToken() {
const username = 'noteuser' + Date.now();
await request(app)
.post('/api/auth/register')
.send({ username, password: 'testpass123' });
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username, password: 'testpass123' });
return loginRes.body.accessToken;
}
describe('Notes Endpoints', () => {
// Test that requests without a token are rejected
it('should reject unauthenticated access', async () => {
const res = await request(app).get('/api/notes');
expect(res.statusCode).toBe(401);
});
Why use Date.now() in the username?
The in-memory user store persists across tests within a single Jest run. Using Date.now() creates a unique username for each test so they never collide with users registered by other tests.
- Save tests/notes.test.js.
- Add the create-and-retrieve test below the unauthenticated access test by pasting this code:
// Test the full create-and-read flow with a valid token
it('should create and retrieve a note', async () => {
const token = await getAuthToken();
const createRes = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${token}`)
.send({ title: 'Test Note', content: 'This is a test' });
expect(createRes.statusCode).toBe(201);
expect(createRes.body.title).toBe('Test Note');
const getRes = await request(app)
.get('/api/notes')
.set('Authorization', `Bearer ${token}`);
expect(getRes.statusCode).toBe(200);
expect(getRes.body.length).toBeGreaterThan(0);
});
What does this test prove?
This test covers the full lifecycle. It creates a note with a valid Bearer token and checks the 201 status. Then it retrieves the same user's notes and confirms at least one note exists.
- Save tests/notes.test.js.
- Run the tests so far to confirm notes and auth tests both pass:
npm test
You should see 7 tests passing. Five from the auth file and two from the notes file.
- Add the XSS sanitization tests and the closing brace below the create-and-retrieve test by pasting this code:
// Test that malicious onerror attributes are stripped
it('should sanitize XSS in note content', async () => {
const token = await getAuthToken();
const res = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${token}`)
.send({
title: 'XSS Test',
content: '<img src=x onerror="alert(\'xss\')">',
});
expect(res.statusCode).toBe(201);
expect(res.body.content).not.toContain('onerror');
expect(res.body.content).not.toContain('alert');
});
// Test that script tags are stripped from note titles
it('should sanitize script tags in note title', async () => {
const token = await getAuthToken();
const res = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${token}`)
.send({
title: '<script>alert("xss")</script>Safe Title',
content: 'Normal content',
});
expect(res.statusCode).toBe(201);
expect(res.body.title).not.toContain('<script>');
});
});
What do these XSS tests prove?
The first test submits the same <img src=x onerror="alert('xss')"> payload you used in Step 4. It asserts that onerror and alert are gone from the stored content.
The second test checks that <script> tags are stripped from note titles. Together they prove DOMPurify is working on both fields.
- Save tests/notes.test.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved tests/notes.test.js before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm these three chunks are present in order in your tests/notes.test.js:
- Chunk 1: Imports, getAuthToken() helper, describe block opener, and unauthenticated access test.
- Chunk 2: Create and retrieve a note test.
- Chunk 3: XSS content sanitization test, script tag sanitization test, and closing });.
Run tests locally
Both test files are complete. Before you think about CI, run the full suite locally to confirm everything passes.
Before you run this, how many tests do you expect to see? Count the it() blocks across both files.
- Run the full test suite by running this command:
npm test
You should see 9 tests passing across 2 test suites. Five auth tests and four notes tests, all green.
Seeing test failures?
If the XSS sanitization tests fail, make sure sanitizeInput middleware is applied to the POST / route in src/routes/notes.js. Without it, DOMPurify never runs on the request body.
If the refresh token test fails, check that JWT_REFRESH_SECRET is set in your .env file. The refresh endpoint needs a separate secret from the access token.
Help me debug my failing Jest tests
Create the GitHub Actions CI workflow and push
Your tests pass locally. A CI pipeline makes sure they also pass on a clean machine every time you push code. GitHub Actions reads a workflow file from your repo and runs the steps automatically.
- Create the workflow directory by running this command:
New-Item -ItemType Directory -Path ".github\workflows" -Force
- Confirm the directory was created by running:
ls .github\workflows
You should see the empty workflows directory listed.
- Create a new file called .github/workflows/ci.yml and paste the following code:
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
- run: npm ci
- run: npm test
env:
JWT_SECRET: ci-test-secret-key
JWT_REFRESH_SECRET: ci-test-refresh-secret-key
PORT: 3000
What does this workflow do?
The on: block triggers the workflow on every push or pull request to the main branch.
The steps check out your code, set up Node.js 24, install dependencies with npm ci, and run npm test with test-only JWT secrets passed as environment variables.
Why not use GitHub Secrets for these env vars?
The JWT_SECRET and JWT_REFRESH_SECRET values here are throwaway strings used only during CI tests. They never touch production. In a real project with actual secrets, you would store them in GitHub Secrets and reference them with ${{ secrets.JWT_SECRET }} syntax.
- Save .github/workflows/ci.yml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved .github/workflows/ci.yml before moving on.
ⓧ I'd like to double check the full code
name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
- run: npm ci
- run: npm test
env:
JWT_SECRET: ci-test-secret-key
JWT_REFRESH_SECRET: ci-test-refresh-secret-key
PORT: 3000
Now push your code to GitHub so the workflow can run.
- Open your browser and go to github.com.
- Click the + icon in the top right corner.
- Click New repository.
- Enter secure-notes-api as the repository name.
- Leave the repository set to Public.
- Do not add a README, .gitignore, or license. You already have these files locally.
- Click Create repository.
GitHub shows a page with setup instructions. You will use the commands for pushing an existing repository.
- Return to your PowerShell terminal in the secure-notes-api folder.
- Stage and commit all your project files by running these commands:
git add .
git commit -m "Add secure notes API with tests and CI"
- Connect your local repo to GitHub and push by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/secure-notes-api.git
git branch -M main
git push -u origin main
What does git branch -M main do?
Some Git installations default to a branch name like master. The -M flag renames whatever your current branch is to main, which matches the branch name in your CI workflow.
Git push asking for credentials?
If Git prompts for a username and password, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token. Go to GitHub Settings, then Developer settings, then Personal access tokens to generate one. Use the token as your password when Git asks.
Help me push my code to GitHub
Once the push completes, the CI workflow triggers automatically.
- Go to your repository page on GitHub.
- Click the Actions tab at the top of the page.
Before you look at the result, do you think the CI run passed or failed? Your tests passed locally, but the CI environment is a fresh Ubuntu machine with no .env file.
You should see a workflow run called Add secure notes API with tests and CI with a green checkmark. The workflow passes because the ci.yml file supplies its own JWT_SECRET and JWT_REFRESH_SECRET as inline env vars, so the tests do not need the local .env file.
Seeing a red X on the workflow?
Click the failed run to see the logs. The most common cause is a YAML indentation error in ci.yml. YAML is whitespace-sensitive. Make sure the env: block under npm test is indented with exactly 8 spaces.
If the run shows a failing test, compare the error message to your local test output. The same tests that pass locally should pass in CI.
Help me fix my GitHub Actions workflow
Secret mission
Add Role-Based Access Control (RBAC)
Your API treats every authenticated user the same. In production, that is never how it works. Admins need to see everything. Regular users should only see their own data. Can you add role-based access control so an admin can view all notes, while a regular user gets a 403 Forbidden?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources, pause them to come back later, or delete them entirely.
Resources you used:
- secure-notes-api project folder on your Desktop (includes all source code, node_modules/, and your .env file).
- GitHub repository with the Actions CI workflow.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the API.
- Your code stays in the secure-notes-api folder on your Desktop.
- Your GitHub repository and Actions pipeline remain free for public repositories.
- All note data is stored in memory and resets each time you restart the server.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Your code stays saved on disk.
- Stop the server by pressing Ctrl+C in the PowerShell terminal where it is running.
- Restart anytime by navigating to the secure-notes-api folder and running:
node src/server.js
- Your GitHub repository stays as-is. The CI pipeline only runs when you push new commits.
Delete - I don't want to use this again
Remove all project files from your machine and delete the GitHub repository.
- Stop the server by pressing Ctrl+C in the PowerShell terminal if it is still running.
- Delete the project folder by running these commands in PowerShell:
cd ~/Desktop
Remove-Item -Recurse -Force secure-notes-api
- Delete the GitHub repository by opening it on GitHub, clicking Settings at the top of the repository page, scrolling down to the danger zone at the bottom, and selecting the option to delete the repository.
Nice Work!
Nice Work!
Nice work! You just built a production-patterned secure API from the ground up, complete with authentication, interactive documentation, layered XSS defenses, and automated testing.
Here's what you learned:
- Built a full REST API with Express 5 and JWT authentication, including short-lived access tokens, refresh token rotation, algorithm whitelisting, and bcrypt password hashing.
- Auto-generated interactive Swagger UI documentation from JSDoc comments, with Bearer auth support for testing protected endpoints directly from the browser.
- Demonstrated a live XSS attack, then neutralized it with Helmet CSP headers and DOMPurify input sanitization. Wrote Jest tests and wired them into a GitHub Actions CI pipeline that runs automatically on every push.
- Secret Mission: Added role-based access control (RBAC) with an admin role, a requireRole middleware, and comprehensive tests proving that only admins can access the /api/admin/notes endpoint.
Ready to quiz yourself?