Secure a Node.js API Against Injection
Build a vulnerable API, exploit it with SQL injection, then harden it.
Introduction
30 Second Summary
Every time you type into a search bar or login form, your input travels straight to a database. If that database isn't protected, a single cleverly crafted input can expose every record or wipe the entire system clean.
In this project, you will build a Notes API that starts deliberately vulnerable, exploit it yourself with SQL injection attacks, then harden it layer by layer with industry-standard defenses.
What You'll Build
A secure REST API built with Express and SQLite that demonstrates how SQL injection attacks work and how to prevent them using parameterized queries, input validation, and security headers.
By the end of this project, you'll have:
- A working REST API with CRUD endpoints for managing notes, backed by a SQLite database.
- A hands-on understanding of SQL injection by exploiting a vulnerable endpoint yourself, then seeing the attack fail after hardening.
- A defense-in-depth security stack using parameterized queries, input validation middleware, and security headers.
- Secret Mission: Add rate limiting and brute-force protection to your API.
Are there any prerequisites?
None. This guide includes full setup instructions for every tool you need, including Node.js and VS Code. You do not need any prior experience with databases or security.
Before We Start
Before We Start...
Set Up Your Development Environment
Before you can build the Notes API, you need a JavaScript runtime and a few security-focused packages installed on your machine.
In this step, you will install Node.js, create your project folder, and pull in the four packages that power both the vulnerability demo and the security hardening.
In this step, get ready to:
- Install Node.js and verify it is working.
- Create the project directory and install dependencies.
- Open the project in VS Code and verify the installed packages.
Install Node.js and verify the installation
Node.js is the runtime that lets you execute JavaScript outside of a browser. Your project's database library (better-sqlite3) requires Node.js version 20 or later.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Node.js version by running this command:
node --version
✔️ I see version 20 or higher
Nice. Your Node.js installation is ready. Skip ahead to the next substep.
ⓧ I see a version lower than 20
Your current version is too old for better-sqlite3. You need version 20 or later.
- Visit the official Node.js download page at nodejs.org/en/download.
- Download and install the LTS version (currently v24).
- Close and reopen your terminal, then run node --version again to confirm you see v20 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit the official Node.js download page at nodejs.org/en/download.
- Download and install the LTS version (currently v24).
- Follow the installer prompts and accept the defaults.
- Close and reopen your terminal, then run node --version to confirm the install worked.
Create the project directory and install dependencies
Now that Node.js is ready, you will create a dedicated folder for the project and install the four packages you need.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir notes-api
cd notes-api
- Confirm the folder exists by running:
ls ~/Desktop
You should see notes-api in the output.
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file with default values. This file tracks your project's name, version, and dependencies.
The -y flag means "yes to all defaults" so you skip the interactive prompts.
You should see a confirmation message showing the contents of your new package.json.
- In package.json, find this line:
"main": "index.js"
- Change index.js to server.js, so the line now looks like this:
"main": "server.js"
- Save package.json.
- Install the four project dependencies by running:
npm install express better-sqlite3 express-validator helmet
What are these packages?
- express is the web framework that handles HTTP requests and routing.
- better-sqlite3 is a fast, synchronous SQLite database driver with built-in parameterized query support.
- express-validator is middleware that validates and sanitizes user input before it reaches your code.
- helmet sets security-related HTTP headers on every response.
You should see output showing packages being downloaded and added to your node_modules folder.
Seeing install errors?
If you see errors about node-gyp or native compilation, your Node.js version may be too old. Run node --version and confirm it shows v20 or higher.
If you see permission errors, try running the command with sudo on macOS/Linux, or open your terminal as Administrator on Windows.
Help me fix my npm install error for better-sqlite3
Open the project in VS Code and verify the installed packages
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the notes-api folder, then click Open.
In the Explorer sidebar on the left, you should see node_modules, package.json, and package-lock.json.
- Click on package.json to open it.
- Confirm you see all four packages listed under dependencies: express, better-sqlite3, express-validator, and helmet.
✔️ Awesome, I've got everything!
Great. Double check you have saved your package.json file with "main": "server.js".
ⓧ I'd like to double check the full code
Your package.json should contain these key fields. Your file will also have auto-generated fields like description, scripts, and license. The version numbers under dependencies will show specific numbers like ^4.21.2 instead of latest.
{
"name": "notes-api",
"version": "1.0.0",
"main": "server.js",
"dependencies": {
"better-sqlite3": "latest",
"express": "latest",
"express-validator": "latest",
"helmet": "latest"
}
}
Don't see node_modules?
Make sure you opened the notes-api folder itself in VS Code, not the Desktop folder that contains it.
If node_modules is missing, go back to your terminal, confirm you are inside the notes-api directory, and run npm install again.
Help me figure out why node_modules is missing
Your development environment is ready. Next up, you will build a deliberately vulnerable version of the Notes API so you can see exactly how SQL injection attacks work.
Build a Vulnerable Notes API
Your project has all its dependencies installed. Now you need an API that connects to a database and lets users create, read, update, and delete notes.
In this step, you will build a deliberately vulnerable version of the API. The endpoints will use dangerous string concatenation to build SQL queries. This is the mistake that causes SQL injection attacks in real applications. You need to see it working before you can understand why it is dangerous.
In this step, get ready to:
- Create a SQLite database with a notes table.
- Build Express endpoints that use unsafe string concatenation for SQL queries.
- Test the API with curl to confirm it works for normal requests.
Create the SQLite database and notes table
Before writing any endpoints, you need a database to store notes. better-sqlite3 is a fast, synchronous SQLite library for Node.js. It stores the entire database in a single file on disk, so there is no server to install or configure.
- Create a new file called database.js in your notes-api folder by running this command:
touch database.js
- Confirm the file exists by running:
ls
You should see database.js listed alongside package.json and node_modules.
- Open database.js in VS Code and add the following code:
const Database = require('better-sqlite3');
const path = require('path');
// Create (or open) a SQLite database file called notes.db
const db = new Database(path.join(__dirname, 'notes.db'));
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Create the notes table if it does not already exist
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
module.exports = db;
What does this code do?
This file sets up a SQLite database that lives in a single file called notes.db. Every time you require this module, it gives you a ready-to-use database connection.
- db.pragma('journal_mode = WAL') enables Write-Ahead Logging, which makes reads and writes faster when they happen at the same time.
- db.exec() runs the SQL that creates a notes table with four columns: an auto-incrementing id, a title, content, and a created_at timestamp.
- module.exports = db exports the connection so other files (like your server) can use it.
- Save database.js.
You will not see a change yet. The next chunk uses this database in your server.
✔️ Awesome, I've got everything!
Great. Make sure you have saved database.js before moving on.
ⓧ I'd like to double check the full code
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'notes.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
module.exports = db;
Build the Express server with vulnerable endpoints
Now you will create the API server. This version intentionally uses string concatenation to build SQL queries. That is the exact pattern that makes real applications vulnerable to SQL injection.
- Create a new file called server.js by running:
touch server.js
- Open server.js in VS Code and add the server setup and read endpoints:
const express = require('express');
const db = require('./database');
const app = express();
const PORT = 3000;
// Parse JSON request bodies
app.use(express.json());
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
// GET search notes - VULNERABLE: user input directly in SQL string
app.get('/notes/search', (req, res) => {
const searchTerm = req.query.q;
const notes = db.prepare(`SELECT * FROM notes WHERE title LIKE '%${searchTerm}%' OR content LIKE '%${searchTerm}%'`).all();
res.json(notes);
});
// GET single note - VULNERABLE: user input directly in SQL string
app.get('/notes/:id', (req, res) => {
const note = db.prepare(`SELECT * FROM notes WHERE id = ${req.params.id}`).get();
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
What does this code do?
This sets up an Express server on port 3000 with three GET endpoints.
- GET /notes returns all notes. This one is safe because it has no user input in the query.
- GET /notes/search takes a query parameter q and drops it directly into the SQL string using ${searchTerm}. An attacker can type SQL code as the search term and it will execute.
- GET /notes/:id injects the URL parameter directly into the query. The same vulnerability applies here.
You will not see a change yet. The next chunk adds the write endpoints and starts the server.
- Below the GET endpoints in server.js, add the create, update, and delete endpoints plus the server start:
// POST create note - VULNERABLE: user input directly in SQL string
app.post('/notes', (req, res) => {
const { title, content } = req.body;
const result = db.prepare(`INSERT INTO notes (title, content) VALUES ('${title}', '${content}')`).run();
const note = db.prepare(`SELECT * FROM notes WHERE id = ${result.lastInsertRowid}`).get();
res.status(201).json(note);
});
// PUT update note - VULNERABLE: user input directly in SQL string
app.put('/notes/:id', (req, res) => {
const { title, content } = req.body;
const result = db.prepare(`UPDATE notes SET title = '${title}', content = '${content}' WHERE id = ${req.params.id}`).run();
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare(`SELECT * FROM notes WHERE id = ${req.params.id}`).get();
res.json(note);
});
// DELETE note - VULNERABLE: user input directly in SQL string
app.delete('/notes/:id', (req, res) => {
const result = db.prepare(`DELETE FROM notes WHERE id = ${req.params.id}`).run();
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
// Start the server
app.listen(PORT, () => {
console.log(`Notes API running on http://localhost:${PORT}`);
});
What does this code do?
These endpoints handle creating, updating, and deleting notes. Every one of them drops user input directly into SQL strings using template literals.
- POST /notes takes title and content from the request body and concatenates them into an INSERT statement.
- PUT /notes/:id does the same with an UPDATE statement.
- DELETE /notes/:id concatenates the id parameter into a DELETE statement.
- app.listen() starts the server on port 3000.
- Save server.js.
- Start the server by running:
node server.js
You should see this output in your terminal:
Notes API running on http://localhost:3000
Not seeing the server start?
- If you see Cannot find module './database', make sure database.js is saved in the same folder as server.js.
- If you see Cannot find module 'express', run npm install again to reinstall dependencies.
- If port 3000 is already in use, close any other processes using that port and try again.
Help me debug why my server won't start.
✔️ Awesome, I've got everything!
Great. Keep the server running in your terminal. You will need a second terminal for the next substep.
ⓧ I'd like to double check the full code
const express = require('express');
const db = require('./database');
const app = express();
const PORT = 3000;
// Parse JSON request bodies
app.use(express.json());
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
// GET search notes - VULNERABLE: user input directly in SQL string
app.get('/notes/search', (req, res) => {
const searchTerm = req.query.q;
const notes = db.prepare(`SELECT * FROM notes WHERE title LIKE '%${searchTerm}%' OR content LIKE '%${searchTerm}%'`).all();
res.json(notes);
});
// GET single note - VULNERABLE: user input directly in SQL string
app.get('/notes/:id', (req, res) => {
const note = db.prepare(`SELECT * FROM notes WHERE id = ${req.params.id}`).get();
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
// POST create note - VULNERABLE: user input directly in SQL string
app.post('/notes', (req, res) => {
const { title, content } = req.body;
const result = db.prepare(`INSERT INTO notes (title, content) VALUES ('${title}', '${content}')`).run();
const note = db.prepare(`SELECT * FROM notes WHERE id = ${result.lastInsertRowid}`).get();
res.status(201).json(note);
});
// PUT update note - VULNERABLE: user input directly in SQL string
app.put('/notes/:id', (req, res) => {
const { title, content } = req.body;
const result = db.prepare(`UPDATE notes SET title = '${title}', content = '${content}' WHERE id = ${req.params.id}`).run();
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare(`SELECT * FROM notes WHERE id = ${req.params.id}`).get();
res.json(note);
});
// DELETE note - VULNERABLE: user input directly in SQL string
app.delete('/notes/:id', (req, res) => {
const result = db.prepare(`DELETE FROM notes WHERE id = ${req.params.id}`).run();
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
// Start the server
app.listen(PORT, () => {
console.log(`Notes API running on http://localhost:${PORT}`);
});
Test the API with normal requests
Your server is running but you have not confirmed it actually handles requests correctly. You will use curl to send HTTP requests to your API from a second terminal window.
- Open a new terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Navigate to your project folder by running:
cd ~/Desktop/notes-api
- Create a new note by running:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "My First Note", "content": "Hello from the API"}'
You should see a JSON response with your new note, including an id, title, content, and created_at timestamp.
- Retrieve all notes by running:
curl -s http://localhost:3000/notes
You should see an array containing the note you just created.
- Search for your note by running:
curl -s "http://localhost:3000/notes/search?q=First"
You should see the same note returned because its title contains the word "First".
Why test with normal requests first?
These requests prove the API works correctly under normal conditions. In the next step, you will send malicious requests to the same endpoints. Seeing it work normally first makes the contrast with an attack much clearer.
Getting connection refused?
- Make sure your server is still running in the first terminal window. You should see the Notes API running on http://localhost:3000 message there.
- If the server crashed, switch back to that terminal and run node server.js again.
- Make sure you are using port 3000, not a different port number.
Help me figure out why my curl requests are failing.
Your API works perfectly for normal requests. The endpoints create, read, search, update, and delete notes without any issues. In the next step, you will exploit this exact API with SQL injection payloads to see what an attacker could do with those vulnerable string concatenations.
Exploit Your Own API with SQL Injection
Your Notes API is running and handling normal requests without any issues. Now it's time to put on your attacker hat and find out just how vulnerable that string concatenation approach really is.
SQL injection is one of the oldest and most dangerous web vulnerabilities. It's ranked #3 on the OWASP Top 10 list of web application security risks. In this step, you'll exploit your own API to see exactly how it works.
In this step, get ready to:
- Bypass the search filter using a SQL injection payload to return all notes.
- Drop the entire notes table using a destructive injection payload.
- Understand why string concatenation makes your database vulnerable.
Bypass the search filter with SQL injection
SQL injection works by inserting malicious SQL code into a query through user input. When the server builds a query by gluing user input directly into the SQL string, an attacker can change the query's meaning entirely.
To demonstrate this, you need some notes in your database. Let's create two notes with different topics so the bypass is obvious.
- Make sure your server is still running from the previous step. If it's not, start it again with node server.js in your terminal.
- Create your first test note by running this command:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Meeting Notes", "content": "Discuss project deadline with the team"}'
- Create a second note on a completely different topic by running:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Shopping List", "content": "Buy groceries for the weekend"}'
You should see JSON responses confirming both notes were created.
Now try a normal search to see how it works. Search for notes containing "meeting" so only the first note should appear.
- Run a normal search request by running:
curl -s "http://localhost:3000/notes/search?q=meeting"
You should see only the Meeting Notes result returned. The search filter is working correctly for normal input.
Now here's where it gets interesting. Instead of a normal search term, you'll send a carefully crafted string that changes the SQL query itself.
- Send the SQL injection payload by running:
curl -s "http://localhost:3000/notes/search?q=' OR '1'='1"
What does this payload do?
When the server concatenates this input into the SQL query, the ' character closes the original string literal early. Then OR '1'='1 adds a condition that is always true.
The resulting query now matches every row in the table. Instead of filtering notes by your search term, the query returns everything in the database.
You should see both notes returned in the response, including the Shopping List note that has nothing to do with "meeting". The search filter has been completely bypassed.
Seeing an empty response or connection error?
Make sure your server is still running in another terminal tab. If you see Connection refused, restart it with node server.js.
If you see an empty array, make sure you created both test notes earlier in this substep.
Help me debug my SQL injection attempt
Drop the notes table with a destructive payload
Bypassing a search filter is bad enough. But SQL injection can do far worse. An attacker can use the same technique to delete your entire database.
- Send a destructive SQL injection payload that drops the notes table by running:
curl -s "http://localhost:3000/notes/search?q='; DROP TABLE notes; --"
What does this payload do?
The ' closes the original string. The ; ends the first SQL statement. Then DROP TABLE notes executes a completely new SQL command that permanently deletes the entire table and all its data.
The -- at the end is a SQL comment. It tells the database to ignore anything that comes after it, preventing syntax errors from leftover query fragments.
Now verify the damage.
- Try to fetch all notes by running:
curl -s http://localhost:3000/notes
You should see an error response because the notes table no longer exists. Every single note has been permanently destroyed.
Still seeing notes returned?
If the DROP TABLE payload did not take effect, your server may be using a single-statement execution method that ignores the second command. Stop the server with Ctrl+C, delete the notes.db file, restart with node server.js, create test notes again, and retry the payload.
Help me troubleshoot my DROP TABLE injection
Understand why string concatenation is dangerous
You just destroyed your own database with a single curl command. Let's break down why this was possible.
Why is string concatenation dangerous for SQL?
When you build SQL queries by concatenating user input directly into the query string, the database cannot tell the difference between your code and the attacker's code. The input becomes part of the SQL structure itself.
This is called a confused deputy problem. The database trusts the server to send valid queries. The server trusts user input to be harmless data. An attacker exploits this trust chain by sending input that is actually executable code.
The fix is to keep code and data separate. That's exactly what parameterized queries do, and you'll implement them in the next step.
Before moving on, let's restore your database so you have a working API for the next step.
- Stop the server by pressing Ctrl+C in the terminal where it's running.
- Restart the server by running:
node server.js
The CREATE TABLE IF NOT EXISTS statement in database.js automatically recreates the notes table when the server starts.
- Confirm the table is back by running this in a separate terminal:
curl -s http://localhost:3000/notes
You should see an empty array [] returned. The table exists again, but all your previous notes are gone forever.
What did you just prove?
You proved that a single malicious request can bypass access controls, steal data, or destroy an entire database. SQL injection attacks happen every day against production applications that use string concatenation for database queries.
You've seen the damage firsthand. Next up, you'll rewrite every query using parameterized queries to make these same attacks completely harmless.
Harden the Database Layer with Parameterized Queries
In Step 3, you watched SQL injection destroy your database in real time. String concatenation in SQL queries hands control of your database to anyone who can type in a text field.
The fix is parameterized queries. Instead of inserting user input directly into SQL strings, you use ? placeholders that tell the database engine to treat input as data. The database will never execute user input as SQL commands.
In this step, get ready to:
- Rewrite all SQL queries to use ? placeholders with db.prepare().
- Test the same injection payloads and confirm they are safely treated as literal text.
- Verify the database initialization recreates dropped tables automatically.
Rewrite all SQL queries with parameterized statements
You are going to replace the entire vulnerable server.js file with a hardened version. Every SQL query will use better-sqlite3's db.prepare() method with ? placeholders instead of string concatenation.
- Open server.js in VS Code.
- Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Paste in the following code to set up the Express server and the first endpoint:
const express = require('express');
const db = require('./database');
const app = express();
const PORT = 3000;
app.use(express.json());
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
What does this code do?
The db.prepare() method compiles a SQL statement into a prepared statement. The database engine parses the SQL structure once and locks it in. No user input can change what the query does.
Calling .all() executes the prepared statement and returns all matching rows as an array.
- Below the GET all notes endpoint, add the search and single-note endpoints:
// GET search notes
app.get('/notes/search', (req, res) => {
const searchTerm = `%${req.query.q}%`;
const notes = db.prepare('SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?').all(searchTerm, searchTerm);
res.json(notes);
});
// GET single note
app.get('/notes/:id', (req, res) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
How do the ? placeholders work?
Each ? in the SQL string is a slot for user input. When you call .all(searchTerm, searchTerm) or .get(req.params.id), the values fill the slots in order.
The database treats these values as literal data. Even if someone types ' OR '1'='1, the database searches for that exact string rather than executing it as SQL logic.
- Below the GET endpoints, add the write endpoints and the server listener:
// POST create note
app.post('/notes', (req, res) => {
const { title, content } = req.body;
const result = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)').run(title, content);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
});
// PUT update note
app.put('/notes/:id', (req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE notes SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
res.json(note);
});
// DELETE note
app.delete('/notes/:id', (req, res) => {
const result = db.prepare('DELETE FROM notes WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
app.listen(PORT, () => {
console.log(`Secure Notes API running on http://localhost:${PORT}`);
});
What do .run() and result.changes do?
The .run() method executes INSERT, UPDATE, or DELETE statements. It returns an info object with two useful properties.
- result.lastInsertRowid gives you the ID of the row you just inserted. This lets you immediately fetch the new note back.
- result.changes tells you how many rows were affected. If it is 0, the note did not exist, so you return a 404.
- Save server.js.
Seeing a syntax error when you save?
Make sure you deleted all the old vulnerable code before pasting. Leftover lines from the previous version will cause conflicts.
Check that the template literal on the console.log line uses backticks (`) not regular quotes.
Help me find the syntax error in my server.js file.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const db = require('./database');
const app = express();
const PORT = 3000;
app.use(express.json());
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
// GET search notes
app.get('/notes/search', (req, res) => {
const searchTerm = `%${req.query.q}%`;
const notes = db.prepare('SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?').all(searchTerm, searchTerm);
res.json(notes);
});
// GET single note
app.get('/notes/:id', (req, res) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
// POST create note
app.post('/notes', (req, res) => {
const { title, content } = req.body;
const result = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)').run(title, content);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
});
// PUT update note
app.put('/notes/:id', (req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE notes SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
res.json(note);
});
// DELETE note
app.delete('/notes/:id', (req, res) => {
const result = db.prepare('DELETE FROM notes WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
app.listen(PORT, () => {
console.log(`Secure Notes API running on http://localhost:${PORT}`);
});
Test the same injection payloads from Step 3
Time to prove your fix works. You will run the exact same attack payloads that destroyed your database in Step 3. This time, they should fail harmlessly.
- Start your server by running:
node server.js
- You should see Secure Notes API running on http://localhost:3000 in your terminal.
- Open a second terminal window. Create a test note by running:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Test Note", "content": "This is a normal note"}'
- You should see a JSON response with your new note, including an id, title, content, and created_at field.
- Now try the injection payload that previously returned all notes. Run:
curl -s "http://localhost:3000/notes/search?q=' OR '1'='1"
- You should see an empty array []. The database searched for the literal string ' OR '1'='1 and found no matches.
- Now try the DROP TABLE injection. Run:
curl -s "http://localhost:3000/notes/search?q='; DROP TABLE notes; --"
- You should see an empty array [] again. The table is still intact.
- Confirm the table survived by fetching all notes:
curl -s http://localhost:3000/notes
- You should see your test note in the response. The table was never dropped.
Why did the injection fail?
With parameterized queries, the database engine separates SQL logic from data. The ? placeholder tells the engine that whatever value fills this slot is data only. It can never become part of the SQL command structure.
This is OWASP's number one defense against SQL injection: Prepared Statements with Parameterized Queries.
Still seeing injection succeed?
Make sure you saved server.js and restarted the server after saving. The old vulnerable code might still be running.
Check that your queries use ? placeholders and pass values as arguments to .all() or .get(), not inside the SQL string.
Help me figure out why my parameterized queries are not blocking injection.
Verify the database initialization handles dropped tables
In Step 3, the DROP TABLE injection may have destroyed your notes table. When you started the server just now, it came back automatically. That recovery happened because of a single line in database.js.
- Open database.js and find this line:
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
Why does IF NOT EXISTS matter for security?
The IF NOT EXISTS clause means this statement is safe to run every time the server starts. If the table exists, nothing happens. If the table was dropped (by an attacker or by accident), it gets recreated automatically.
This is part of defense in depth. Even if an attack somehow gets through, your application can recover on restart without manual intervention.
- Confirm database.js matches the code above. No changes are needed. This file was already set up correctly in Step 2.
Your database layer is now hardened. Parameterized queries block injection at the query level. The CREATE TABLE IF NOT EXISTS statement provides automatic recovery at the schema level. Next up, you will add input validation middleware to catch bad data before it even reaches the database.
Add Input Validation with express-validator
Parameterized queries stopped injection attacks at the database layer. But what happens when a user sends a title that is 50,000 characters long?
It won't trigger an injection, but it will bloat your database and degrade performance. Input validation catches bad data at the door before it ever reaches your route handlers. In this step, you'll add express-validator middleware that enforces rules on every incoming request.
In this step, get ready to:
- Add validation middleware to the create and update endpoints.
- Add validation middleware to the search endpoint.
- Test with invalid inputs and confirm the API returns 400 error responses.
Add validation middleware to the create and update endpoints
The express-validator library lets you chain validation rules directly in your route definitions. Each rule runs as middleware before your handler executes.
- Stop your running server by pressing Ctrl+C in the terminal.
- In server.js, find this line at the top of your file:
const express = require('express');
- Add the express-validator import directly below it:
const { body, query, validationResult } = require('express-validator');
- Now find this line further down:
app.use(express.json());
- Add this helper function below it:
// Validation error handler helper
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
What does this code do?
The import pulls three tools from express-validator: body() validates request body fields, query() validates URL query parameters, and validationResult() collects any validation errors that occurred.
The handleValidationErrors function checks if any validation rules failed. If they did, it sends back a 400 response with the error details. If everything passed, it calls next() to continue to your route handler.
Now you'll update the POST endpoint to validate incoming note data before saving it.
- Find the // POST create note section in server.js.
- Replace the entire route handler (from app.post to the closing );) with this validated version:
// POST create note
app.post('/notes',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)').run(title, content);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
}
);
What does this code do?
The body('title') and body('content') lines are middleware that run BEFORE your route handler. They trim whitespace, check the field isn't empty, and enforce a maximum length.
Each .withMessage() call sets a human-readable error message that gets returned to the user if that rule fails. The handleValidationErrors middleware then checks all the results and either rejects the request or passes it through.
You won't see a change yet. The next chunk applies the same pattern to the update endpoint.
- Find the // PUT update note section in server.js.
- Replace the entire route handler with this validated version:
// PUT update note
app.put('/notes/:id',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE notes SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
res.json(note);
}
);
What does this code do?
This is the same validation pattern as the create endpoint. Both title and content are trimmed, required, and length-limited. The only difference is the SQL query inside the handler (UPDATE instead of INSERT).
- Save server.js.
Seeing a syntax error when you save?
Make sure the closing ); from the old route handler is fully removed. If you left behind a stray parenthesis or brace, Node.js will throw an unexpected token error.
Also check that each body() line ends with a comma. Forgetting the comma between middleware arguments is a common mistake.
Help me debug a syntax error in my express-validator middleware
Add validation middleware to the search endpoint
The search endpoint accepts user input through a URL query parameter. This needs validation too, because an attacker could send extremely long search strings or embed HTML characters in the query.
- Find the // GET search notes section in server.js.
- Replace the entire route handler with this validated version:
// GET search notes
app.get('/notes/search',
query('q')
.trim()
.notEmpty().withMessage('Search query is required')
.isLength({ max: 100 }).withMessage('Search query must be 100 characters or less')
.escape(),
handleValidationErrors,
(req, res) => {
const searchTerm = `%${req.query.q}%`;
const notes = db.prepare('SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?').all(searchTerm, searchTerm);
res.json(notes);
}
);
What does this code do?
The query('q') function from express-validator validates the URL query parameter named q. This is different from req.query.q inside the handler, which reads the actual value from Express's request object.
The .escape() call at the end converts HTML special characters (like < and >) into safe entities. This prevents stored cross-site scripting if search terms are ever displayed back to users.
- Save server.js.
- Restart your server by running:
node server.js
- You should see Secure Notes API running on http://localhost:3000 in your terminal.
Server won't start?
Check that you imported query from express-validator in the import line at the top. If you only imported body and validationResult, the search route will fail because query is undefined.
Help me debug my express-validator search route
Test with invalid inputs
Your validation middleware is in place. Time to prove it works by sending requests that should be rejected.
- Open a second terminal window.
- Test sending a note with an empty title by running:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "", "content": "Some content"}'
- You should see a JSON response with a 400 status and an error message saying the title is required.
What should I see?
The response should look something like:
{"errors":[{"msg":"Title is required","path":"title","location":"body"}]}
The exact fields may vary slightly, but you should see the error message you defined in your .withMessage() call.
- Test the search endpoint with an empty query by running:
curl -s "http://localhost:3000/notes/search?q="
- You should see a 400 response with an error saying the search query is required.
- Now test that valid requests still work by running:
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Valid Note", "content": "This should work fine"}'
- You should see a 201 response with the newly created note, confirming that valid data passes through.
Getting a 200 instead of 400 for invalid data?
This means your validation middleware isn't running. Check that handleValidationErrors appears as an argument in your route definition, between the body() validators and the (req, res) => handler.
My express-validator validation isn't catching invalid input
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js file at this point:
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const db = require('./database');
const app = express();
const PORT = 3000;
app.use(express.json());
// Validation error handler helper
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
// GET search notes
app.get('/notes/search',
query('q')
.trim()
.notEmpty().withMessage('Search query is required')
.isLength({ max: 100 }).withMessage('Search query must be 100 characters or less')
.escape(),
handleValidationErrors,
(req, res) => {
const searchTerm = `%${req.query.q}%`;
const notes = db.prepare('SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?').all(searchTerm, searchTerm);
res.json(notes);
}
);
// GET single note
app.get('/notes/:id', (req, res) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
// POST create note
app.post('/notes',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)').run(title, content);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
}
);
// PUT update note
app.put('/notes/:id',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE notes SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
res.json(note);
}
);
// DELETE note
app.delete('/notes/:id', (req, res) => {
const result = db.prepare('DELETE FROM notes WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
app.listen(PORT, () => {
console.log(`Secure Notes API running on http://localhost:${PORT}`);
});
Your API now rejects bad data before it touches the database. Next up, you'll add security headers to protect against a whole category of browser-based attacks.
Add Security Headers with Helmet
Your API now validates input and uses parameterized queries to block injection attacks at the database level. There is one more layer of defense to add: HTTP response headers.
Every time your server sends a response, it includes headers that tell browsers how to handle the content. Without security headers, browsers fall back to permissive defaults that attackers can exploit. Helmet is a middleware that sets these headers for you in one line of code. In this step, you will add helmet to your API and run a comprehensive security test to prove all your defenses work together.
In this step, get ready to:
- Configure helmet middleware to set security headers on all responses.
- Inspect response headers with curl to verify they are set correctly.
- Run a comprehensive security test script that proves your API is hardened.
Import and configure helmet middleware
Helmet sets 11+ security headers on every response your server sends. You already installed it as a dependency in Step 1, so now you just need to import it and register it as middleware.
- In server.js, find this line at the top of the file:
const express = require('express');
- Add the helmet import directly below it, so the top of your file now looks like this:
const express = require('express');
const helmet = require('helmet');
- Now find this line further down:
app.use(express.json());
- Add app.use(helmet()) directly above it, so the middleware section now looks like this:
app.use(helmet());
app.use(express.json());
Why does helmet go first?
Middleware in Express runs in the order you register it. By placing helmet before everything else, security headers are set on every response your server sends, even error responses.
- Save server.js.
- Stop your running server with Ctrl+C in the terminal, then restart it by running:
node server.js
You should see Secure Notes API running on http://localhost:3000 in the terminal.
Server won't start?
Make sure you added const helmet = require('helmet'); as a separate line below the express require. A missing semicolon or typo in the module name will cause a crash on startup.
help me debug my server startup error after adding helmet
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const helmet = require('helmet');
const { body, query, validationResult } = require('express-validator');
const db = require('./database');
const app = express();
const PORT = 3000;
app.use(helmet());
app.use(express.json());
// Validation error handler helper
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
// GET all notes
app.get('/notes', (req, res) => {
const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all();
res.json(notes);
});
// GET search notes
app.get('/notes/search',
query('q')
.trim()
.notEmpty().withMessage('Search query is required')
.isLength({ max: 100 }).withMessage('Search query must be 100 characters or less')
.escape(),
handleValidationErrors,
(req, res) => {
const searchTerm = `%${req.query.q}%`;
const notes = db.prepare('SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?').all(searchTerm, searchTerm);
res.json(notes);
}
);
// GET single note
app.get('/notes/:id', (req, res) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
// POST create note
app.post('/notes',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)').run(title, content);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
}
);
// PUT update note
app.put('/notes/:id',
body('title')
.trim()
.notEmpty().withMessage('Title is required')
.isLength({ max: 200 }).withMessage('Title must be 200 characters or less'),
body('content')
.trim()
.notEmpty().withMessage('Content is required')
.isLength({ max: 5000 }).withMessage('Content must be 5000 characters or less'),
handleValidationErrors,
(req, res) => {
const { title, content } = req.body;
const result = db.prepare('UPDATE notes SET title = ?, content = ? WHERE id = ?').run(title, content, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
res.json(note);
}
);
// DELETE note
app.delete('/notes/:id', (req, res) => {
const result = db.prepare('DELETE FROM notes WHERE id = ?').run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).send();
});
app.listen(PORT, () => {
console.log(`Secure Notes API running on http://localhost:${PORT}`);
});
Inspect the response headers with curl
With helmet active, every response from your API now carries security headers that instruct browsers to enable protective features. You can see these headers directly using curl.
- Open a second terminal window (keep your server running in the first one).
- Inspect the response headers from your API by running:
curl -s -I http://localhost:3000/notes
What does this command do?
The -I flag tells curl to show only the response headers, not the body. The -s flag silences the progress bar.
You should see security headers in the output, including lines like:
- content-security-policy — tells browsers which content sources are trusted.
- x-content-type-options — prevents browsers from MIME-sniffing responses.
- x-frame-options — blocks your page from being embedded in an iframe.
What about X-Powered-By?
By default, Express adds an X-Powered-By: Express header to every response. This tells attackers exactly what framework you are running, making it easier to target known Express vulnerabilities.
Helmet removes this header automatically. Scan your curl output and confirm that x-powered-by does not appear anywhere in the list.
Not seeing security headers?
Make sure you restarted the server after saving your changes. Changes to server.js only take effect after a restart.
Check that app.use(helmet()) appears before app.use(express.json()) in your file. If it is registered after your routes, it will not apply to those routes.
help me figure out why my helmet security headers are not appearing in curl output
Create and run the security test script
You have verified the headers manually. Now you will create a test script that proves all your security layers work together: parameterized queries, input validation, and security headers.
- Create a new file called test-security.sh in your notes-api folder and paste the following code into it:
#!/bin/bash
echo "=== Security Test Suite ==="
echo ""
# Create a normal note first
echo "1. Creating a normal note..."
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Test Note", "content": "This is a normal note"}' | head -c 200
echo ""
echo ""
# Test SQL injection in search
echo "2. Testing SQL injection in search..."
RESPONSE=$(curl -s "http://localhost:3000/notes/search?q=' OR '1'='1")
echo "Response: $RESPONSE"
echo ""
# Test SQL injection in search with DROP TABLE
echo "3. Testing DROP TABLE injection..."
RESPONSE=$(curl -s "http://localhost:3000/notes/search?q='; DROP TABLE notes; --")
echo "Response: $RESPONSE"
echo ""
# Verify table still exists
echo "4. Verifying notes table still exists..."
curl -s http://localhost:3000/notes | head -c 200
echo ""
echo ""
# Test input validation - empty title
echo "5. Testing empty title validation..."
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title": "", "content": "Some content"}' | head -c 200
echo ""
echo ""
# Test input validation - title too long
echo "6. Testing title length validation..."
LONG_TITLE=$(python3 -c "print('A' * 201)")
curl -s -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d "{\"title\": \"$LONG_TITLE\", \"content\": \"Some content\"}" | head -c 200
echo ""
echo ""
# Test security headers
echo "7. Checking security headers..."
curl -s -I http://localhost:3000/notes | grep -i -E "(x-powered-by|content-security-policy|x-content-type-options|x-frame-options)"
echo ""
echo "=== Tests Complete ==="
What does this script test?
- Tests 1-4 verify your parameterized queries block SQL injection (including a DROP TABLE attempt).
- Tests 5-6 verify your input validation rejects empty and oversized titles.
- Test 7 checks that helmet's security headers are present and X-Powered-By is absent.
- Save test-security.sh.
- Make the script executable by running:
chmod +x test-security.sh
- Run the security test suite by running:
bash test-security.sh
You should see all 7 tests complete successfully. The injection attempts return validation errors instead of leaking data. The header check shows content-security-policy, x-content-type-options, and x-frame-options are all present.
Seeing permission denied?
Make sure you ran chmod +x test-security.sh before executing the script. On Windows, use Git Bash or WSL to run bash scripts.
If test 6 fails, check that you have python3 available in your terminal. The test uses it to generate a long string.
help me debug my security test script
Your API is now hardened with three layers of defense: parameterized queries at the database level, input validation at the middleware level, and security headers at the HTTP level. Next up, you will take on a secret mission to add rate limiting and brute-force protection.
Secret mission
Add Rate Limiting and Brute-Force Protection
Your API is now secure against injection attacks. But what about someone who sends thousands of requests per second to overwhelm your server? In this secret mission, you will add rate limiting to protect your API against brute-force and denial-of-service attacks.
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 on disk, pause the running server, or delete everything entirely.
Resources you used:
- Your project folder (contains database.js, server.js, test-security.sh, node_modules/, and notes.db).
- Node.js runtime (installed in Step 1).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your project folder stays on your Desktop.
- Node.js remains installed and available for other projects.
- The notes.db file keeps any notes you created during testing.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your files stay on disk so you can pick up where you left off.
- In the terminal where your server is running, press Ctrl+C to stop the process.
- Close VS Code if you are done for now.
- When you come back, open the project folder in VS Code and run node server.js to start the server again.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- In the terminal where your server is running, press Ctrl+C to stop the process.
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the project folder by running:
macOS
rm -rf secure-notes-api
Windows
rmdir /s /q secure-notes-api
- Confirm the folder is gone by running:
ls
You should no longer see your project folder in the output.
To uninstall Node.js:
- On Windows, open Settings, select Apps, find Node.js in the list, and click Uninstall.
- On macOS, the uninstall method depends on how you installed Node.js. If you used a version manager like nvm, run nvm uninstall followed by your version number. If you used the official installer, follow the uninstall guide on the Node.js website.
Nice Work!
Nice Work!
You just built a secure Node.js API from scratch and proved it can withstand real injection attacks.
You've learned how to:
- Exploit a vulnerable API with SQL injection to understand how attackers compromise databases firsthand.
- Harden the database layer with parameterized queries using better-sqlite3, OWASP's #1 recommended defense against injection.
- Add a security middleware stack with express-validator for input validation and helmet for security headers, following industry best practices.
- Secret Mission: Added rate limiting to protect against brute-force and denial-of-service attacks.
Ready to quiz yourself?