Build a Secure Notes App
Build a full-stack notes app with JWT auth, bcrypt, and protected API routes.
Introduction
30 Second Summary
Every time you log into an app, there is a system behind the scenes that verifies your identity and makes sure you only see your own data. That system involves hashed passwords, tokens, and protected routes.
In this project, you will build a full-stack secure notes application where users can register, log in, and manage private notes that only they can access.
What You'll Build
A complete notes app with an Express.js backend handling authentication and a React frontend for login, registration, and notes management.
By the end of this project, you'll have:
- A MySQL database with users and notes tables storing your application data.
- A REST API with JWT authentication and bcrypt password hashing that blocks unauthorized access.
- A React frontend that handles the full authentication flow and communicates with protected API endpoints.
- Secret Mission: Add an edit note feature with a PUT endpoint and inline editing UI.
Are there any prerequisites?
You should be familiar with basic React concepts (components, props, useState, useEffect), basic SQL queries (SELECT, INSERT, DELETE), and running terminal commands.
You will also need VS Code, Node.js, and XAMPP installed on your machine. Step 1 covers setting up the project dependencies.
Set Up the Backend Project and Database
Every secure web app needs a solid foundation before you write any features. Your backend will handle user registration, authenticate login requests, and store private notes in a database.
In this step, you'll create the Node.js project, install the packages that power authentication and database connections, and set up a MySQL database to store users and notes.
In this step, get ready to:
- Create a Node.js project and install backend dependencies.
- Set up a MySQL database with users and notes tables.
- Create environment configuration and database connection files.
Create the project folder and install dependencies
- 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.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a Node.js project by running these commands:
mkdir secure-notes-app
cd secure-notes-app
npm init -y
What do these commands do?
- mkdir secure-notes-app creates a new folder called secure-notes-app on your Desktop.
- cd secure-notes-app moves your terminal inside that folder.
- npm init -y creates a package.json file that tracks your project's dependencies and settings.
- Confirm the project was created by running:
ls
You should see package.json in the output.
Don't see package.json?
Make sure you ran all three commands in order. If you see an error on mkdir, the folder might already exist. Try cd secure-notes-app first, then run npm init -y.
help me troubleshoot why my project folder wasn't created correctly
- Install the backend dependencies by running:
npm install express mysql2 bcryptjs jsonwebtoken dotenv cors
You should see output showing packages being added. This may take a minute.
What are these packages?
- express is the web framework for building your API routes.
- mysql2 connects your app to the MySQL database.
- bcryptjs hashes passwords so they're never stored in plain text.
- jsonwebtoken creates and verifies JWT tokens for user authentication.
- dotenv loads environment variables from a .env file.
- cors allows your React frontend to communicate with the backend across different ports.
Seeing npm ERR! messages?
Check that you have an internet connection. If you see permission errors, try running the command with administrator privileges. On macOS, prefix with sudo. On Windows, right-click your terminal and select Run as administrator.
help me fix my npm install error
Set up the MySQL database
Your app needs a database to store user accounts and notes. You'll use phpMyAdmin (a browser-based tool that comes with XAMPP) to create the database and tables.
- Open the XAMPP Control Panel.
- Click Start next to Apache.
- Click Start next to MySQL.
Why start both Apache and MySQL?
MySQL is your database server. Apache is the web server that powers phpMyAdmin's browser interface. You need both running to manage your database through the browser.
- Open your browser and navigate to:
http://localhost/phpmyadmin
You should see the phpMyAdmin dashboard load.
Page not loading?
Make sure both Apache and MySQL show green in the XAMPP Control Panel. If MySQL won't start, another MySQL instance might be using port 3306. Close any other database tools and try again.
help me fix phpMyAdmin not loading in XAMPP
- In phpMyAdmin, click New in the left sidebar.
- Type secure_notes in the Create database field.
- Click Create.
You should see secure_notes appear in the left sidebar.
Now you'll create two tables: one for user accounts and one for notes. Each table defines the columns that structure your data.
- Click on secure_notes in the left sidebar to select it.
- Click the SQL tab at the top of the page.
- Create the users table by pasting this SQL into the query box and clicking Go:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
What does this table store?
- id is a unique number that auto-increments for each new user.
- email stores the user's email. UNIQUE prevents duplicate accounts.
- password stores the hashed password (never the plain text).
- created_at automatically records when the account was created.
You should see a green success message confirming the table was created.
- Clear the query box, then create the notes table by pasting this SQL and clicking Go:
CREATE TABLE notes (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
What does this table store?
- user_id links each note to the user who created it.
- title and content hold the note's data.
- FOREIGN KEY (user_id) REFERENCES users(id) enforces that every note must belong to an existing user.
- Verify both tables exist by clicking secure_notes in the left sidebar.
You should see both users and notes listed as tables.
Seeing a SQL error?
Make sure you selected the secure_notes database in the left sidebar before running the queries. Also check that you ran the users table first, since the notes table references it.
help me fix my SQL table creation error in phpMyAdmin
Create the environment and database connection files
Your app needs to know how to connect to the database without hardcoding sensitive details like passwords directly in the code. You'll store these values in an environment file and create a reusable database connection.
- 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.
- Select the secure-notes-app folder on your Desktop, then click Open.
- Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS / Ctrl+N on Windows).
- Name the file .env and add the following content:
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=
DB_NAME=secure_notes
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
PORT=3000
What do these values mean?
- DB_HOST, DB_USER, DB_PASSWORD are the default XAMPP MySQL credentials (root user, no password).
- DB_NAME matches the database you just created in phpMyAdmin.
- JWT_SECRET is the key used to sign authentication tokens. In production, you'd use a long random string.
- PORT sets which port your backend server listens on.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Create another new file in the same folder and name it db.js. This file sets up a connection pool to your MySQL database. Add the following code:
const mysql = require('mysql2/promise');
require('dotenv').config();
// Create a pool of database connections
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool;
What does this code do?
- mysql2/promise imports the promise-based version of the MySQL driver so you can use async/await.
- dotenv.config() loads the variables from your .env file into process.env.
- createPool() creates a pool of reusable database connections instead of opening a new connection for every request. This is more efficient.
- module.exports = pool makes the pool available to other files in your project.
- Save db.js.
You should now see both .env and db.js in the VS Code Explorer sidebar alongside package.json and the node_modules folder.
Not seeing your files in the sidebar?
Make sure you opened the secure-notes-app folder (not your Desktop or a parent folder). Check that you saved both files after adding the content.
help me find my project files in VS Code
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here's what your .env should look like:
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=
DB_NAME=secure_notes
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
PORT=3000
Here's what your db.js should look like:
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
});
module.exports = pool;
Your backend foundation is in place. You have a database ready to store users and notes, and a connection pool that your server can use to talk to it. Next up, you'll build the Express server and create the authentication routes that let users register and log in.
Build the Express Server with Auth Routes
Your database is ready and your connection pool is configured. Now you need the API layer that sits between your future React frontend and the MySQL database.
In this step, you will build an Express.js server with two authentication routes: one for registering new users and one for logging in. The register route hashes passwords before storing them. The login route verifies credentials and returns a JWT token.
In this step, get ready to:
- Create an Express server with CORS and JSON middleware.
- Build a registration endpoint that hashes passwords with bcrypt.
- Build a login endpoint that returns a signed JWT token.
Create the Express server
Express is the framework that handles incoming HTTP requests and sends back responses. Before adding any authentication logic, you need a basic server that starts up and confirms it can respond to requests.
- Create a new file called server.js in your project root folder (the same folder that has db.js and package.json).
- Paste the following code into server.js:
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const app = express();
// Allow requests from the React frontend
app.use(cors());
// Parse JSON request bodies
app.use(express.json());
// Test route to confirm the server is running
app.get('/', (req, res) => {
res.json({ message: 'Secure Notes API is running' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- cors() allows your React frontend (running on a different port) to make requests to this server.
- express.json() parses incoming JSON request bodies so you can access them via req.body.
- The test route at / returns a simple JSON message to confirm the server is alive.
- dotenv.config() loads your .env variables (like PORT and JWT_SECRET) into process.env.
- Save server.js.
- Start the server by running this command in your terminal:
node server.js
- You should see Server running on http://localhost:3000 in the terminal.
- Open your browser and go to http://localhost:3000. You should see the JSON message {"message":"Secure Notes API is running"}.
- Press Ctrl+C in the terminal to stop the server for now.
Server not starting?
- If you see Error: Cannot find module 'express', make sure you ran npm install in Step 1. Your terminal must be inside the project folder.
- If port 3000 is already in use, check that you don't have another server running. Stop it or change the PORT value in your .env file.
Why won't my Express server start?
Build the registration endpoint
The registration endpoint needs to do several things safely: validate the input, check that the email is not already taken, hash the password, and store the new user. Storing passwords as plain text would be a critical security vulnerability.
Bcrypt transforms the password into a one-way hash that cannot be reversed. Even if someone steals the database, they cannot recover the original passwords.
- Create a folder called routes inside your project root folder.
- Create a new file called auth.js inside the routes folder.
- Paste the following code into routes/auth.js:
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = require('../db');
const router = express.Router();
// POST /register - Create a new user account
router.post('/register', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Enforce minimum password length
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
try {
// Check if email is already taken (parameterized query prevents SQL injection)
const [existing] = await pool.execute(
'SELECT id FROM users WHERE email = ?',
[email]
);
if (existing.length > 0) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash the password with 10 salt rounds
const hashedPassword = await bcrypt.hash(password, 10);
// Insert the new user with the hashed password
await pool.execute(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
res.status(201).json({ message: 'User registered successfully' });
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
What does this code do?
- pool.execute('SELECT id FROM users WHERE email = ?', [email]) is a parameterized query. The ? placeholder keeps user input completely separate from the SQL logic, preventing SQL injection attacks.
- bcrypt.hash(password, 10) hashes the password with 10 salt rounds. Salt rounds control how computationally expensive the hash is to generate, making brute-force attacks impractical.
- The route returns 409 for duplicate emails, 400 for invalid input, and 201 on success. These HTTP status codes tell the frontend exactly what happened.
Why parameterized queries instead of string concatenation?
If you built the SQL string by pasting user input directly into it (like 'SELECT * FROM users WHERE email = "' + email + '"'), an attacker could type a specially crafted email that changes the query's meaning. This is called SQL injection.
Parameterized queries using ? placeholders send the SQL structure and the data separately to MySQL. The database treats user input as pure data, never as executable SQL. This eliminates the injection risk entirely.
- Save routes/auth.js.
You won't see a change yet because the route is not connected to the server. You will wire it in after adding the login endpoint.
Seeing a 'Cannot find module' error?
- Make sure your routes folder is inside the project root (same level as db.js), not inside node_modules or another subfolder.
- Confirm that require('../db') in auth.js uses ../ to go up one level from the routes folder to the project root where db.js lives.
Help me fix my require path for db.js in my routes folder.
Add the login endpoint and wire auth routes into the server
The login endpoint does the reverse of registration. It finds the user by email, uses bcrypt to compare the provided password against the stored hash, and returns a signed JWT token if the credentials are valid.
The token acts as a temporary pass. The frontend stores it and sends it with every future request to prove the user is authenticated.
- In routes/auth.js, find the line that says module.exports = router; at the bottom of the file.
- Paste the following code directly above that line:
// POST /login - Authenticate user and return JWT
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
try {
// Find the user by email
const [users] = await pool.execute(
'SELECT id, email, password FROM users WHERE email = ?',
[email]
);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const user = users[0];
// Compare the provided password against the stored hash
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Generate a JWT token valid for 1 hour
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Server error' });
}
});
What does this code do?
- bcrypt.compare(password, user.password) checks whether the plain-text password matches the stored hash. It returns true or false without ever decrypting the hash.
- jwt.sign() creates a token containing the user's ID and email. The token expires after 1 hour, so stolen tokens have a limited window of use.
- Both the wrong-email and wrong-password cases return the same error message: Invalid email or password. This prevents attackers from discovering which emails are registered in your system.
- Save routes/auth.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = require('../db');
const router = express.Router();
// POST /register - Create a new user account
router.post('/register', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Enforce minimum password length
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
try {
// Check if email is already taken (parameterized query prevents SQL injection)
const [existing] = await pool.execute(
'SELECT id FROM users WHERE email = ?',
[email]
);
if (existing.length > 0) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash the password with 10 salt rounds
const hashedPassword = await bcrypt.hash(password, 10);
// Insert the new user with the hashed password
await pool.execute(
'INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]
);
res.status(201).json({ message: 'User registered successfully' });
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ error: 'Server error' });
}
});
// POST /login - Authenticate user and return JWT
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
try {
// Find the user by email
const [users] = await pool.execute(
'SELECT id, email, password FROM users WHERE email = ?',
[email]
);
if (users.length === 0) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const user = users[0];
// Compare the provided password against the stored hash
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Generate a JWT token valid for 1 hour
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
Now you need to tell your server to use these auth routes. Without wiring them in, Express does not know they exist.
- Open server.js.
- Replace the entire contents of the file with this updated version:
const express = require('express');
const cors = require('cors');
require('dotenv').config();
// Import the auth route handlers
const authRoutes = require('./routes/auth');
const app = express();
// Allow requests from the React frontend
app.use(cors());
// Parse JSON request bodies
app.use(express.json());
// Mount auth routes at /api/auth
app.use('/api/auth', authRoutes);
// Test route to confirm the server is running
app.get('/', (req, res) => {
res.json({ message: 'Secure Notes API is running' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- app.use('/api/auth', authRoutes) mounts your auth router at the /api/auth prefix. This means your register endpoint becomes /api/auth/register and login becomes /api/auth/login.
- The middleware order matters. cors() and express.json() must come before route mounting so they process every incoming request first.
- Save server.js.
- Start the server by running:
node server.js
- You should see Server running on http://localhost:3000 in the terminal.
- Test the register endpoint by opening a new terminal window and running this command:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d "{\"email\":\"test@example.com\",\"password\":\"password123\"}"
- You should see {"message":"User registered successfully"} in the response.
- Now test the login endpoint by running:
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d "{\"email\":\"test@example.com\",\"password\":\"password123\"}"
- You should see a JSON response containing a token field (a long string of characters) and a user object with id and email.
Using Postman instead of curl
If you prefer a visual tool, you can use Postman to send the same requests. Set the method to POST, the URL to http://localhost:3000/api/auth/register, and the body to raw JSON with email and password fields.
Getting a connection error or 500 response?
- Check that XAMPP is running with both Apache and MySQL started. The MySQL service must be active for database queries to work.
- Verify your .env file has DB_NAME=secure_notes and the database actually exists in phpMyAdmin.
- If you see ER_TABLE_DOESNT_EXIST, make sure you created the users table in Step 1.
Help me debug my auth routes returning 500 errors.
✔️ 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
const express = require('express');
const cors = require('cors');
require('dotenv').config();
// Import the auth route handlers
const authRoutes = require('./routes/auth');
const app = express();
// Allow requests from the React frontend
app.use(cors());
// Parse JSON request bodies
app.use(express.json());
// Mount auth routes at /api/auth
app.use('/api/auth', authRoutes);
// Test route to confirm the server is running
app.get('/', (req, res) => {
res.json({ message: 'Secure Notes API is running' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your authentication API is working. Anyone can now register and log in to get a token. Next up, you will build the middleware that checks that token on every request and create the protected notes routes that only authenticated users can access.
Build JWT Middleware and Protected Notes API
Your auth routes can register users and hand out JWT tokens. But a token is useless unless something checks it before granting access to protected data.
In this step, you will build the gatekeeper that sits in front of your notes routes. Every request to create, read, or delete notes must present a valid token first. If the token is missing or expired, the request gets blocked with a 401 error.
In this step, get ready to:
- Create auth middleware that verifies JWT tokens on every request.
- Build protected notes routes (GET, POST, DELETE) that only respond to authenticated users.
- Test that unauthenticated requests are blocked and authenticated requests succeed.
Create the authentication middleware
Middleware in Express.js is a function that runs before your route handler. It can inspect the request, reject it, or modify it before passing control to the next function in the chain.
Your auth middleware will extract the token from the request header, verify it, and attach the user's identity to the request object. If anything fails, it sends back a 401 status code immediately.
- Create a new folder called middleware inside your project root (the same level as server.js).
- Create a file called auth.js inside the middleware folder.
- Add the following code to middleware/auth.js:
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
// Extract the Authorization header from the incoming request
const authHeader = req.headers['authorization'];
// Split "Bearer <token>" to get just the token part
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
// Verify the token signature and check expiration
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach the decoded user data (userId, email) to the request
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authenticateToken;
What does this code do?
The authenticateToken function follows the Express middleware pattern: it takes req, res, and next as arguments.
- It reads the Authorization header and extracts the token after the word "Bearer".
- jwt.verify() checks that the token was signed with your secret and has not expired. If verification fails, it throws an error.
- On success, the decoded payload (containing userId and email) gets attached to req.user so route handlers can identify who is making the request.
- next() passes control to the actual route handler. Without calling next(), the request would hang forever.
- Save middleware/auth.js.
You will not see a change yet. The next substep creates the routes that use this middleware.
Why write the middleware manually?
Libraries like express-jwt can do this for you in one line. But writing it by hand teaches you exactly how token verification works. In a real codebase, you will encounter custom middleware constantly.
Build the protected notes routes
Now you will create the API endpoints that let authenticated users manage their notes. Every query uses the userId from the verified token to filter results. This means one user can never see or delete another user's notes.
Your server.js already imports and mounts routes/notes.js at /api/notes. You just need to create the file with the actual route handlers.
- Open routes/notes.js and replace its contents with the following code that sets up the imports and a GET route to fetch notes:
const express = require('express');
const pool = require('../db');
const authenticateToken = require('../middleware/auth');
const router = express.Router();
// Apply auth middleware to ALL routes in this file
router.use(authenticateToken);
// GET / - Fetch all notes belonging to the authenticated user
router.get('/', async (req, res) => {
try {
const [notes] = await pool.execute(
'SELECT id, title, content, created_at FROM notes WHERE user_id = ? ORDER BY created_at DESC',
[req.user.userId]
);
res.json(notes);
} catch (err) {
console.error('Fetch notes error:', err);
res.status(500).json({ error: 'Server error' });
}
});
What does this code do?
- router.use(authenticateToken) applies the middleware to every route in this file. No route handler runs unless the token is valid.
- The GET route uses a parameterized query (? placeholder) to fetch only notes where user_id matches the authenticated user. This prevents SQL injection and enforces data isolation.
- req.user.userId comes from the decoded JWT payload that the middleware attached in the previous step.
- Save routes/notes.js and restart your server by pressing Ctrl+C in the terminal, then running:
node server.js
You should see Server running on http://localhost:3000 without any errors. The server can now load your notes routes.
Seeing 'Cannot find module' errors?
- Check that the middleware folder is at the same level as server.js, not inside the routes folder.
- Verify the import path in routes/notes.js uses ../middleware/auth (two dots to go up one directory level).
Why is my server crashing on startup?
- Now add the POST route for creating notes. Add the following code below the GET route handler in routes/notes.js:
// POST / - Create a new note for the authenticated user
router.post('/', async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Title and content are required' });
}
try {
const [result] = await pool.execute(
'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)',
[req.user.userId, title, content]
);
res.status(201).json({
id: result.insertId,
title,
content,
created_at: new Date().toISOString(),
});
} catch (err) {
console.error('Create note error:', err);
res.status(500).json({ error: 'Server error' });
}
});
What does this code do?
- It validates that both title and content are provided in the request body. Missing fields return a 400 error.
- The INSERT query uses req.user.userId to tie the new note to the authenticated user. Nobody can create notes under someone else's account.
- result.insertId is the auto-generated ID that MySQL assigned to the new row. The response returns it so the frontend knows the note's ID.
- Add the DELETE route and the module export below the POST route in routes/notes.js:
// DELETE /:id - Delete a note (only if it belongs to the authenticated user)
router.delete('/:id', async (req, res) => {
try {
const [result] = await pool.execute(
'DELETE FROM notes WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.json({ message: 'Note deleted' });
} catch (err) {
console.error('Delete note error:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
What does this code do?
- The DELETE query includes AND user_id = ? as a second condition. Even if someone guesses another user's note ID, the query will not match and affectedRows will be 0.
- When affectedRows === 0, it means either the note does not exist or it belongs to someone else. Either way, the user gets a 404.
- Save routes/notes.js.
✔️ Awesome, I've got everything!
Make sure you have saved the file. You will test it in the next substep.
ⓧ I'd like to double check the full code
const express = require('express');
const pool = require('../db');
const authenticateToken = require('../middleware/auth');
const router = express.Router();
router.use(authenticateToken);
router.get('/', async (req, res) => {
try {
const [notes] = await pool.execute(
'SELECT id, title, content, created_at FROM notes WHERE user_id = ? ORDER BY created_at DESC',
[req.user.userId]
);
res.json(notes);
} catch (err) {
console.error('Fetch notes error:', err);
res.status(500).json({ error: 'Server error' });
}
});
router.post('/', async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: 'Title and content are required' });
}
try {
const [result] = await pool.execute(
'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)',
[req.user.userId, title, content]
);
res.status(201).json({
id: result.insertId,
title,
content,
created_at: new Date().toISOString(),
});
} catch (err) {
console.error('Create note error:', err);
res.status(500).json({ error: 'Server error' });
}
});
router.delete('/:id', async (req, res) => {
try {
const [result] = await pool.execute(
'DELETE FROM notes WHERE id = ? AND user_id = ?',
[req.params.id, req.user.userId]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'Note not found' });
}
res.json({ message: 'Note deleted' });
} catch (err) {
console.error('Delete note error:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
Test the protected API
Time to prove the middleware works. You will send requests with and without a token to confirm that unauthenticated users get blocked.
- Restart your server by pressing Ctrl+C in the terminal, then running:
node server.js
- Open a second terminal window.
- Test that requests without a token are blocked by running:
curl http://localhost:3000/api/notes
You should see a 401 response: {"error":"Access token required"}. The middleware blocked the request because no token was provided.
- Now log in to get a valid token. Run this command (use the credentials you registered in the previous step):
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d "{\"email\":\"test@example.com\",\"password\":\"password123\"}"
Windows users
If you are using Command Prompt and the escaped quotes do not work, try using PowerShell instead. Alternatively, use a tool like Postman to send requests with a visual interface.
The response includes a token field. Copy the long string value (it looks like eyJhbGciOiJIUzI1NiIs...).
- Create a note using the token. Replace YOUR_TOKEN_HERE with the token you just copied:
curl -X POST http://localhost:3000/api/notes -H "Content-Type: application/json" -H "Authorization: Bearer YOUR_TOKEN_HERE" -d "{\"title\":\"My First Note\",\"content\":\"Hello from the secure API\"}"
You should see a 201 response with the new note's id, title, and content returned.
- Fetch your notes to confirm it was saved by running:
curl http://localhost:3000/api/notes -H "Authorization: Bearer YOUR_TOKEN_HERE"
You should see a JSON array containing the note you just created.
Getting 401 even with a token?
- Make sure you include the word Bearer (with a capital B) before the token in the Authorization header.
- Tokens expire after 1 hour. If you took a long break, log in again to get a fresh token.
- Check that the JWT_SECRET value in your .env file has not changed since you logged in. A different secret means old tokens will not verify.
Help me debug my JWT authentication issues.
Your API is fully protected. Unauthenticated requests get stopped at the gate, and every note is tied to the user who created it. Next up, you will build the React frontend that talks to these endpoints and gives users a real interface for managing their notes.
Build the React Frontend
Your backend API is fully functional. It can register users, log them in, and manage notes behind protected routes.
Now you need a user interface so real people can interact with those endpoints. In this step, you will build a React frontend with login, register, and notes pages that communicate with your Express API.
In this step, get ready to:
- Scaffold a React project with Vite and set up client-side routing.
- Create login and register pages that communicate with your auth API.
- Build a notes page that fetches, creates, and deletes notes using your protected endpoints.
Scaffold the React project and set up routing
Your frontend and backend are separate projects that talk to each other over HTTP. You will scaffold the React app inside a client/ subfolder of your existing project, keeping the code organized.
- Open a new terminal tab or window. Keep your backend server running in the original terminal.
- Navigate to your project root folder (where server.js lives) and scaffold the React project by running this command:
npm create vite@latest client -- --template react
What does this command do?
This tells Vite to create a new React project inside a folder called client. The --template react flag skips the interactive prompts and uses the React template directly.
If you see a prompt asking to install create-vite, type y and press Enter to continue.
You should see a success message showing the scaffolded project structure.
- Move into the client folder and install all dependencies by running these commands:
cd client
npm install
npm install react-router-dom
What does this install?
The first npm install grabs the base React dependencies from the scaffold. The second installs react-router-dom, which gives your app page navigation without full page reloads.
Seeing permission errors or ENOENT?
Make sure you ran cd client first. If your terminal is still in the project root, npm install will try to install in the wrong folder.
help me fix npm install errors in my Vite React project
- Create a pages folder inside src/ by running this command:
mkdir src/pages
This folder will hold your Login, Register, and Notes page components.
Now you need to replace the boilerplate App component with your routing logic. The app needs to decide which page to show based on the URL and whether the user is logged in.
- Open src/App.jsx in VS Code.
- Delete all the existing content and replace it with the following code:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useState } from 'react';
import Login from './pages/Login';
import Register from './pages/Register';
import Notes from './pages/Notes';
function App() {
// Check localStorage for an existing token on first load
const [token, setToken] = useState(localStorage.getItem('token'));
// Store the token in localStorage and update state
const handleLogin = (newToken) => {
localStorage.setItem('token', newToken);
setToken(newToken);
};
// Remove the token to log the user out
const handleLogout = () => {
localStorage.removeItem('token');
setToken(null);
};
return (
<BrowserRouter>
<div className="app">
<nav>
<h1>Secure Notes</h1>
{token && <button onClick={handleLogout}>Logout</button>}
</nav>
<Routes>
<Route
path="/login"
element={token ? <Navigate to="/notes" /> : <Login onLogin={handleLogin} />}
/>
<Route
path="/register"
element={token ? <Navigate to="/notes" /> : <Register />}
/>
<Route
path="/notes"
element={token ? <Notes token={token} /> : <Navigate to="/login" />}
/>
<Route path="*" element={<Navigate to={token ? '/notes' : '/login'} />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;
What does this code do?
- token state checks localStorage on first load. If the user previously logged in, their token persists across page refreshes.
- Each <Route> uses the token to decide what to render. Logged-in users get redirected away from /login. Unauthenticated users get redirected away from /notes.
- The wildcard route path="*" catches any unknown URL and sends the user to the right place based on login state.
- Save App.jsx.
What about main.jsx?
The src/main.jsx file that Vite created already does what you need. It imports App, imports index.css, and renders the app inside StrictMode. Leave it as-is.
Create the login and register pages
Your routes reference Login and Register components that do not exist yet. Each page is a form that sends credentials to your backend API and handles the response.
- Create a new file at src/pages/Login.jsx and paste the following code:
import { useState } from 'react';
import { Link } from 'react-router-dom';
function Login({ onLogin }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// Send login credentials to the backend
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
// Pass the token up to App so it can store it
onLogin(data.token);
} catch (err) {
setError('Failed to connect to server');
}
};
return (
<div className="auth-form">
<h2>Login</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Login</button>
</form>
<p>
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
);
}
export default Login;
What does this code do?
- handleSubmit sends a POST request to your backend login endpoint with the email and password.
- If the response is successful, it calls onLogin(data.token) which passes the JWT token up to App.jsx for storage in localStorage.
- If login fails, the error message from your API (like "Invalid email or password") displays to the user.
- Save Login.jsx.
- Create a new file at src/pages/Register.jsx and paste the following code:
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
try {
// Send registration data to the backend
const response = await fetch('http://localhost:3000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
// Show success message and redirect to login after 2 seconds
setSuccess('Account created! Redirecting to login...');
setTimeout(() => navigate('/login'), 2000);
} catch (err) {
setError('Failed to connect to server');
}
};
return (
<div className="auth-form">
<h2>Register</h2>
{error && <p className="error">{error}</p>}
{success && <p className="success">{success}</p>}
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Register</button>
</form>
<p>
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
);
}
export default Register;
What does this code do?
- handleSubmit sends a POST request to your backend register endpoint.
- On success, it displays a confirmation message and automatically redirects to the login page after 2 seconds using useNavigate.
- Registration does NOT automatically log the user in. The user must log in separately to receive their JWT token.
- Save Register.jsx.
Seeing red squiggly lines in VS Code?
Your editor might show import errors for ./pages/Login and ./pages/Register in App.jsx. This is normal because the Notes page does not exist yet. The errors disappear once you create all the page files.
help me understand import errors in my React app
Create the notes page and add styling
The Notes page is the core of your app. It fetches the logged-in user's notes from the API, lets them add new notes, and provides a delete button on each card.
- Create a new file at src/pages/Notes.jsx and paste the following code:
import { useState, useEffect } from 'react';
function Notes({ token }) {
const [notes, setNotes] = useState([]);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [error, setError] = useState('');
// Base URL for all notes API requests
const API_URL = 'http://localhost:3000/api/notes';
// Fetch all notes for the authenticated user
const fetchNotes = async () => {
try {
const response = await fetch(API_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
setError('Failed to fetch notes');
return;
}
const data = await response.json();
setNotes(data);
} catch (err) {
setError('Failed to connect to server');
}
};
// Load notes when the component first renders
useEffect(() => {
fetchNotes();
}, []);
// Create a new note via the API
const handleAddNote = async (e) => {
e.preventDefault();
setError('');
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
// Clear the form and refresh the notes list
setTitle('');
setContent('');
fetchNotes();
} catch (err) {
setError('Failed to create note');
}
};
// Delete a note by ID
const handleDelete = async (id) => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchNotes();
}
} catch (err) {
setError('Failed to delete note');
}
};
return (
<div className="notes-page">
<h2>My Notes</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleAddNote} className="note-form">
<input
type="text"
placeholder="Note title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="Note content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<button type="submit">Add Note</button>
</form>
<div className="notes-list">
{notes.length === 0 ? (
<p>No notes yet. Create your first note above!</p>
) : (
notes.map((note) => (
<div key={note.id} className="note-card">
<h3>{note.title}</h3>
<p>{note.content}</p>
<small>{new Date(note.created_at).toLocaleDateString()}</small>
<button onClick={() => handleDelete(note.id)}>Delete</button>
</div>
))
)}
</div>
</div>
);
}
export default Notes;
What does this code do?
- Every request includes Authorization: Bearer ${token} in the headers. This is the JWT your backend middleware checks before allowing access.
- useEffect with an empty dependency array [] means "run once when the component loads." This triggers the initial notes fetch.
- After creating or deleting a note, the component calls fetchNotes() again to refresh the list from the server.
- Save Notes.jsx.
The final piece is styling. Vite scaffolded a default index.css file, but you need to replace it with styles for your auth forms, nav bar, and note cards.
- Open src/index.css, delete all existing content, and replace it with the following:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 30px;
}
nav h1 {
font-size: 1.5rem;
color: #2c3e50;
}
nav button {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.auth-form {
max-width: 400px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.auth-form h2 {
margin-bottom: 20px;
text-align: center;
}
.auth-form form {
display: flex;
flex-direction: column;
gap: 15px;
}
.auth-form input {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.auth-form button {
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.auth-form p {
text-align: center;
margin-top: 15px;
}
.error {
color: #e74c3c;
background: #fde8e8;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.success {
color: #27ae60;
background: #e8fde8;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.note-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.note-form input,
.note-form textarea {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.note-form textarea {
min-height: 100px;
resize: vertical;
}
.note-form button {
padding: 12px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.notes-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.note-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.note-card h3 {
margin-bottom: 8px;
color: #2c3e50;
}
.note-card p {
margin-bottom: 10px;
color: #555;
}
.note-card small {
color: #999;
}
.note-card button {
float: right;
background: #e74c3c;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
What does this stylesheet cover?
- .auth-form styles the login and register card with a white background, shadow, and centered layout.
- .note-form styles the "Add Note" form with spacing and a green submit button.
- .note-card gives each note a card-like appearance with a red delete button floated to the right.
- .error and .success provide colored feedback banners for form submissions.
- Save index.css.
- Start the frontend development server by running this command from inside the client folder:
npm run dev
You should see Vite start on http://localhost:5173. Open that URL in your browser. You should see the Login page with a centered white card containing email and password fields.
Seeing a blank page or errors in the console?
- Open your browser's DevTools console (F12). If you see an import error, double-check that all three page files exist in the correct location: src/pages/Login.jsx, src/pages/Register.jsx, and src/pages/Notes.jsx.
- Make sure you deleted the Vite boilerplate import import './App.css' from App.jsx. If that line still exists, you will see a "file not found" error since you replaced the file contents.
- Confirm your backend server is still running on port 3000 in the other terminal.
help me debug my React frontend
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and your browser shows the login form at localhost:5173.
ⓧ I'd like to double check the full code
Here is the complete code for all client files you created in this step. Compare them against your files.
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useState } from 'react';
import Login from './pages/Login';
import Register from './pages/Register';
import Notes from './pages/Notes';
function App() {
const [token, setToken] = useState(localStorage.getItem('token'));
const handleLogin = (newToken) => {
localStorage.setItem('token', newToken);
setToken(newToken);
};
const handleLogout = () => {
localStorage.removeItem('token');
setToken(null);
};
return (
<BrowserRouter>
<div className="app">
<nav>
<h1>Secure Notes</h1>
{token && <button onClick={handleLogout}>Logout</button>}
</nav>
<Routes>
<Route
path="/login"
element={token ? <Navigate to="/notes" /> : <Login onLogin={handleLogin} />}
/>
<Route
path="/register"
element={token ? <Navigate to="/notes" /> : <Register />}
/>
<Route
path="/notes"
element={token ? <Notes token={token} /> : <Navigate to="/login" />}
/>
<Route path="*" element={<Navigate to={token ? '/notes' : '/login'} />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;
import { useState } from 'react';
import { Link } from 'react-router-dom';
function Login({ onLogin }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
onLogin(data.token);
} catch (err) {
setError('Failed to connect to server');
}
};
return (
<div className="auth-form">
<h2>Login</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Login</button>
</form>
<p>
Don't have an account? <Link to="/register">Register</Link>
</p>
</div>
);
}
export default Login;
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
function Register() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
try {
const response = await fetch('http://localhost:3000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
setSuccess('Account created! Redirecting to login...');
setTimeout(() => navigate('/login'), 2000);
} catch (err) {
setError('Failed to connect to server');
}
};
return (
<div className="auth-form">
<h2>Register</h2>
{error && <p className="error">{error}</p>}
{success && <p className="success">{success}</p>}
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Register</button>
</form>
<p>
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
);
}
export default Register;
import { useState, useEffect } from 'react';
function Notes({ token }) {
const [notes, setNotes] = useState([]);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [error, setError] = useState('');
const API_URL = 'http://localhost:3000/api/notes';
const fetchNotes = async () => {
try {
const response = await fetch(API_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
setError('Failed to fetch notes');
return;
}
const data = await response.json();
setNotes(data);
} catch (err) {
setError('Failed to connect to server');
}
};
useEffect(() => {
fetchNotes();
}, []);
const handleAddNote = async (e) => {
e.preventDefault();
setError('');
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
setTitle('');
setContent('');
fetchNotes();
} catch (err) {
setError('Failed to create note');
}
};
const handleDelete = async (id) => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
fetchNotes();
}
} catch (err) {
setError('Failed to delete note');
}
};
return (
<div className="notes-page">
<h2>My Notes</h2>
{error && <p className="error">{error}</p>}
<form onSubmit={handleAddNote} className="note-form">
<input
type="text"
placeholder="Note title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="Note content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<button type="submit">Add Note</button>
</form>
<div className="notes-list">
{notes.length === 0 ? (
<p>No notes yet. Create your first note above!</p>
) : (
notes.map((note) => (
<div key={note.id} className="note-card">
<h3>{note.title}</h3>
<p>{note.content}</p>
<small>{new Date(note.created_at).toLocaleDateString()}</small>
<button onClick={() => handleDelete(note.id)}>Delete</button>
</div>
))
)}
</div>
</div>
);
}
export default Notes;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 30px;
}
nav h1 {
font-size: 1.5rem;
color: #2c3e50;
}
nav button {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.auth-form {
max-width: 400px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.auth-form h2 {
margin-bottom: 20px;
text-align: center;
}
.auth-form form {
display: flex;
flex-direction: column;
gap: 15px;
}
.auth-form input {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.auth-form button {
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.auth-form p {
text-align: center;
margin-top: 15px;
}
.error {
color: #e74c3c;
background: #fde8e8;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.success {
color: #27ae60;
background: #e8fde8;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.note-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.note-form input,
.note-form textarea {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.note-form textarea {
min-height: 100px;
resize: vertical;
}
.note-form button {
padding: 12px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.notes-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.note-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.note-card h3 {
margin-bottom: 8px;
color: #2c3e50;
}
.note-card p {
margin-bottom: 10px;
color: #555;
}
.note-card small {
color: #999;
}
.note-card button {
float: right;
background: #e74c3c;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
Your full-stack app is wired together. The frontend talks to the backend, the backend talks to the database, and JWT tokens tie it all together. Next up, you will put the whole system through its paces and verify that your security features actually work.
Test the Complete Application
Your backend API and React frontend are fully built. Both servers should still be running from the previous steps.
Now it's time to prove that your security features actually work. You'll register users, create notes, and verify that password hashing, JWT authentication, and data isolation all behave correctly.
In this step, get ready to:
- Register a user and verify the password is stored as a bcrypt hash in phpMyAdmin.
- Create notes and confirm the JWT token is sent with each API request.
- Test data isolation between two user accounts and verify unauthorized requests are rejected.
Register a user and verify password hashing
The most important security feature to verify first is password hashing. When a user registers, their password should never be stored as plain text in the database.
- Open your browser and navigate to http://localhost:5173.
- You should see the Login page. Click the Register link at the bottom of the form.
- Enter alice@example.com in the Email field.
- Enter password123 in the Password field.
- Click Register.
You should see a green success message: "Account created! Redirecting to login..." After 2 seconds, you'll be redirected to the Login page.
Now verify that the password was hashed before being stored in the database.
- Open a new browser tab and go to http://localhost/phpmyadmin.
- Click the secure_notes database in the left sidebar.
- Click the users table.
- Click the Browse tab at the top.
You should see one row with the email alice@example.com. The password column should contain a long string starting with $2a$10$ followed by random characters. This is the bcrypt hash.
Why does the hash start with $2a$10$?
The $2a$ prefix identifies the bcrypt algorithm. The 10 is the salt rounds (cost factor) you set in your registration route.
Even if two users pick the same password, their hashes will be different because bcrypt generates a unique random salt for each one. An attacker who gains access to your database cannot reverse these hashes back into plain-text passwords.
Password shows as plain text in phpMyAdmin?
If you see password123 in plain text instead of a hash, the bcrypt step in your registration route is not running. Check that routes/auth.js has the line const hashedPassword = await bcrypt.hash(password, 10) and that hashedPassword (not password) is used in the INSERT query.
help me debug why my password isn't being hashed before saving to the database
Log in, create notes, and inspect the JWT
With your user registered, you can test the login flow and verify that the JWT token is included in every API request your app makes.
- Go back to your app tab at http://localhost:5173.
- Enter alice@example.com and password123 on the Login page.
- Click Login.
You should be redirected to the Notes page. The nav bar now shows a Logout button, and the page displays "No notes yet. Create your first note above!"
- Enter My first note in the title field.
- Enter This is a test note to verify my app works in the content area.
- Click Add Note.
- Create 1-2 more notes with different titles and content.
Your notes should appear as white cards below the form, each with a title, content, date, and a red Delete button.
Now verify that the JWT token is being sent with each request using your browser's DevTools.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Create another note to trigger a new API request.
- Click the notes request in the Network tab list.
- Click the Headers section and scroll down to Request Headers.
You should see an Authorization: Bearer eyJ... header. The long string after "Bearer" is your JWT token that the backend verifies on every request.
What is that long token string?
The JWT token contains three base64-encoded parts separated by dots: a header (algorithm info), a payload (your user ID and email), and a signature (proof the server created it).
Your backend verifies this signature on every request using the JWT_SECRET from your .env file. If anyone tampers with the token, the signature check fails and the request is rejected.
Not seeing the Authorization header?
Make sure you're looking at a request to localhost:3000/api/notes, not a request to the Vite dev server on port 5173. Filter by "Fetch/XHR" in the Network tab to see only API calls.
help me find the JWT Authorization header in my browser DevTools Network tab
Test data isolation and unauthorized access
The most critical security test is confirming that users can only see their own notes. You also need to verify that unauthenticated requests are rejected entirely.
- Click the Logout button in the nav bar.
- Click the Register link to create a second account.
- Enter bob@example.com and password456 for the second user.
- Click Register.
- After the redirect, log in with bob@example.com and password456.
You should see the Notes page with the message "No notes yet. Create your first note above!" even though Alice's account has notes. Bob cannot see Alice's data.
How does data isolation work here?
Every notes query in your API filters by req.user.userId, which comes from the decoded JWT token. The SQL WHERE user_id = ? clause ensures the database only returns notes belonging to the authenticated user.
Finally, verify that the API rejects requests that have no token at all. Open a new terminal and test this directly.
- Send a request to the notes API without an Authorization header by running this command:
curl http://localhost:3000/api/notes
You should see the response {"error":"Access token required"} with a 401 status code. Your auth middleware blocked the request because no token was provided.
Why does this matter?
Without the auth middleware, anyone who knows your API URL could access all notes in the database. The middleware acts as a gatekeeper, requiring proof of identity (the JWT token) before allowing any data access.
Seeing notes from the wrong user?
If Bob can see Alice's notes, check that your SQL queries in routes/notes.js include WHERE user_id = ? with req.user.userId as the parameter. Also verify that your middleware correctly attaches the decoded token to req.user.
help me debug why one user can see another user's notes
Your application is fully functional and secure. You've proven that passwords are hashed, tokens protect routes, and users can only access their own data. Next up, the Secret Mission challenges you to add edit functionality to your notes.
Secret mission
Add Edit Note Functionality
Your notes app lets users create and delete notes, but what about editing them? In this secret mission, you'll add a PUT endpoint to the backend and inline editing to the React frontend, complete with authorization checks.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- XAMPP services (Apache and MySQL running on your machine).
- MySQL database secure_notes with users and notes tables.
- Backend Node.js project with installed dependencies (Express, mysql2, bcryptjs, jsonwebtoken).
- Frontend React project scaffolded with Vite.
- Environment file (.env) containing your JWT secret and database credentials.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the Secret Mission right away.
- Leave XAMPP running with both Apache and MySQL started.
- Keep your backend and frontend servers running in their terminal windows.
- Your project files remain on your Desktop in the secure-notes folder.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact for next time.
- Stop the backend server by pressing Ctrl+C in its terminal window.
- Stop the frontend dev server by pressing Ctrl+C in its terminal window.
- Open XAMPP Control Panel and click Stop next to both Apache and MySQL.
- Your project files and database remain saved. When you return, start XAMPP again, then run node server.js and npm run dev to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop both servers by pressing Ctrl+C in each terminal window.
- Open phpMyAdmin at http://localhost/phpmyadmin, select the secure_notes database, and click Drop to delete it.
- Delete the entire project folder from your Desktop.
- Stop Apache and MySQL in the XAMPP Control Panel if you no longer need them for other projects.
Nice Work!
Nice Work!
You just built a full-stack secure notes application from scratch. You now understand how real-world apps handle user identity, protect data, and prevent common security vulnerabilities.
You've learned how to:
- Build an Express.js REST API with JWT authentication and bcrypt password hashing so plain-text passwords never touch the database.
- Create auth middleware and parameterized SQL queries that block unauthorized access and prevent SQL injection attacks.
- Build a React frontend that manages authentication state, communicates with protected API endpoints, and enforces data isolation so each user only sees their own notes.
- Added edit note functionality with a PUT endpoint and inline editing UI, complete with proper authorization checks on the backend.
Ready to quiz yourself?