Build a Secure Expense Tracker API
Build and deploy a JWT-authenticated REST API for tracking expenses.
Introduction
30 Second Summary
Every budgeting app asks for access to your bank accounts, your spending habits, your personal data. What if you could build your own back-end that keeps your financial information under your control?
In this project, you will build a secure REST API that handles user registration, login, and expense tracking. You'll deploy it to the cloud so you can access it from anywhere.
What You'll Build
Imagine opening your terminal, registering a new account, and adding expenses that only you can see. You share the live URL with a friend who registers their own account, and they can never peek at your data.
By the end of this project, you'll have:
- A live API where you can register, log in, and receive a JWT token proving your identity.
- Full expense management (create, read, update, delete) locked behind authentication so only you can see your data.
- A cloud-deployed URL on Render that you can hit from any device, anywhere in the world.
- Secret Mission: A spending analytics endpoint that calculates your totals by category and monthly breakdowns.
Are there any prerequisites?
None. This guide walks you through setting up every tool you need, including Node.js, VS Code, and a free Render account.
Before We Start
You're about to build a real API that handles user accounts and financial data. Before writing any code, let's lock in what you're solving and why security matters here.
Set Up Your Project
Every API starts as an empty folder. Before you can build routes or add security, you need a working Node.js project with the right packages installed and a server that responds to requests.
In this step, you will create an Express server from scratch. By the end, you will have a running API that you can hit from your terminal.
In this step, get ready to:
- Initialize a Node.js project with all required dependencies.
- Create the project folder structure for routes and middleware.
- Build a basic Express server and confirm it runs.
Initialize the project and install dependencies
- In VS Code, click View in the top menu bar.
- Click Terminal to open the integrated terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir expense-tracker-api
cd expense-tracker-api
- Initialize a new Node.js project by running:
npm init -y
You should see output confirming that package.json was written to your folder, with a default name matching expense-tracker-api.
What did npm init -y do?
This command created a package.json file in your folder. This file tracks your project's name, version, and dependencies. The -y flag skips the interactive questionnaire and fills in defaults automatically.
The default package.json needs some edits before you install dependencies. You will update it to point to the right entry file and list the exact packages this project needs.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the expense-tracker-api folder, and click Open.
- In the Explorer sidebar on the left, click package.json to open it.
- Select all the existing content and replace it with the following:
{
"name": "expense-tracker-api",
"version": "1.0.0",
"description": "Secure expense tracker REST API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "5.2.1",
"jsonwebtoken": "9.0.3",
"bcryptjs": "3.0.3",
"zod": "4.4.3",
"cors": "2.8.6",
"dotenv": "17.4.2"
}
}
What does this file do?
The main field tells Node.js where your app starts (src/index.js). The scripts.start field defines the command that will launch your server in production.
The dependencies section lists every package your API needs: express for the server, jsonwebtoken for authentication tokens, bcryptjs for password hashing, zod for input validation, cors for cross-origin requests, and dotenv for loading environment variables.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Install all the listed dependencies by running this in your terminal:
npm install
You should see output showing packages being downloaded and a node_modules folder appearing in your Explorer sidebar.
Seeing errors during install?
If you see permission errors, try running the command again with administrator privileges. If you see version conflicts, make sure you saved package.json before running npm install.
Express 5.2.1 requires Node.js 18 or higher. Run node --version to confirm yours is at least v18.
Help me fix my npm install error
- Create the folder structure for your project by running:
mkdir -p src/routes src/middleware
- Confirm the folders were created by running:
ls src
You should see middleware and routes listed in the output.
Now you will create two files: a .env file for secrets and configuration, and src/index.js for your server code.
- In the Explorer sidebar, hover over your project name and click the New File icon.
- Type .env as the filename and press Enter.
- Paste the following into your new .env file:
JWT_SECRET=your-super-secret-key-change-this-in-production
PORT=3000
What is a .env file?
A .env file stores configuration values that should not be hardcoded into your source code. JWT_SECRET is the key used to sign authentication tokens. PORT sets which port your server listens on.
The dotenv package reads this file and makes these values available as process.env.JWT_SECRET and process.env.PORT in your code.
- Save .env.
- Now create the server file. In the Explorer sidebar, right-click the src folder and click New File.
- Type index.js as the filename and press Enter.
- Paste the following code into src/index.js:
// Load environment variables from .env file
require("dotenv").config();
const express = require("express");
const cors = require("cors");
// Create the Express app
const app = express();
// Enable CORS and JSON body parsing
app.use(cors());
app.use(express.json());
// Welcome route to confirm the API is running
app.get("/", (req, res) => {
res.json({ message: "Expense Tracker API is running" });
});
// Start the server on the configured port
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
The first line loads your .env variables into process.env so the rest of your code can access them.
The cors() middleware allows requests from other domains (important when a front-end app calls your API). The express.json() middleware parses incoming JSON request bodies so you can read them with req.body.
The GET / route returns a JSON message confirming the server is alive. The app.listen call starts the server and prints the port number to your terminal.
- Save src/index.js.
Before you run your server, what do you think will happen when you execute node src/index.js? What output do you expect to see in the terminal?
- Start your server by running:
node src/index.js
You should see Server running on port 3000 printed in your terminal. Your API is alive.
- Open a second terminal by clicking the + icon in your terminal panel.
- Test the welcome route by running:
curl http://localhost:3000
You should see {"message":"Expense Tracker API is running"} returned in your terminal.
Don't see the server running?
If you see Cannot find module, make sure you saved src/index.js and that the file is inside the src folder (not in the project root).
If you see EADDRINUSE, another process is already using port 3000. Stop it with Ctrl+C in any other terminal, then try again.
Help me debug my server startup error
✔️ Awesome, I've got everything!
Great. Make sure both files are saved. You can stop the server with Ctrl+C when you are ready to move on.
ⓧ I'd like to double check the full code
Here are the three files you should have at this point. Compare yours against these:
{
"name": "expense-tracker-api",
"version": "1.0.0",
"description": "Secure expense tracker REST API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "5.2.1",
"jsonwebtoken": "9.0.3",
"bcryptjs": "3.0.3",
"zod": "4.4.3",
"cors": "2.8.6",
"dotenv": "17.4.2"
}
}
JWT_SECRET=your-super-secret-key-change-this-in-production
PORT=3000
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Expense Tracker API is running" });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your project foundation is solid. You have a running Express server with all the packages you need for authentication, validation, and security. Next up, you will build the expense routes that give your API something worth protecting.
Build Your Expense Routes
Your server is running and responds with a welcome message. Now you need something for it to actually do. An expense tracker needs routes that create, read, update, and delete expenses.
In this step, you will build a complete set of CRUD routes for expenses using an Express Router. You will also add input validation with Zod so that bad data never enters your system.
In this step, get ready to:
- Create an expenses router with GET and POST routes.
- Add PUT and DELETE routes for updating and removing expenses.
- Test all four CRUD operations with curl from your terminal.
Create the expenses router with in-memory storage
An Express Router is a mini-app that handles a group of related routes. You will create one dedicated to expenses, with a Zod schema that validates every expense before it enters the system.
- Create a new file at src/routes/expenses.js and add the following code to set up the router, validation schema, and a GET route:
const express = require("express");
const { z } = require("zod");
const router = express.Router();
// In-memory storage for expenses
const expenses = [];
// Zod schema validates incoming expense data
const expenseSchema = z.object({
amount: z.number().positive(),
category: z.string().min(1),
description: z.string().optional().default(""),
date: z.string().optional().default(() => new Date().toISOString().split("T")[0]),
});
// GET all expenses
router.get("/", (req, res) => {
res.json(expenses);
});
module.exports = router;
What does this code do?
The expenses array acts as temporary storage. Data lives in memory and resets when the server restarts.
The expenseSchema uses Zod to enforce rules: amount must be a positive number, category cannot be empty, and description and date default to empty string and today's date if omitted.
Now you need to tell your main server file about this new router. This connects the routes to a URL path.
- In src/index.js, find this line near the top:
const cors = require("cors");
- Add the following line directly below it:
const expenseRoutes = require("./routes/expenses");
- Now find the app.get("/", ...) route handler in the same file. Add this line below the closing }); of that route:
app.use("/api/expenses", expenseRoutes);
What does mounting a router do?
The app.use("/api/expenses", expenseRoutes) line tells Express to send any request starting with /api/expenses to your expenses router. Inside the router, / means the base path, so router.get("/") handles GET /api/expenses.
- Save both files.
- If your server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
- Start the server again by running:
node src/index.js
- Open a second terminal window. Test the GET route by running:
curl http://localhost:3000/api/expenses
You should see an empty array [] returned. That confirms your router is mounted and responding.
Seeing "Cannot GET /api/expenses"?
- Make sure you added the require line at the top of src/index.js and the app.use line after the GET / route.
- Confirm you saved both files and restarted the server.
- Check that src/routes/expenses.js ends with module.exports = router; so Express can import it.
Help me debug why my Express router isn't responding at /api/expenses.
Now add the POST route so you can create expenses. This is where Zod validation comes in.
- In src/routes/expenses.js, find the line module.exports = router; and add the following code directly above it:
// POST a new expense (with validation)
router.post("/", (req, res) => {
const result = expenseSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const expense = {
id: Date.now().toString(),
...result.data,
};
expenses.push(expense);
res.status(201).json(expense);
});
What does this code do?
The safeParse method checks the request body against your schema without throwing an error. If validation fails, it returns the issues so the client knows exactly what went wrong.
If validation passes, the route creates an expense object with a unique id (timestamp), pushes it into the array, and responds with 201 Created.
- Save src/routes/expenses.js.
- Stop the server with Ctrl+C and restart it:
node src/index.js
- In your second terminal, create an expense by running:
curl -X POST http://localhost:3000/api/expenses -H "Content-Type: application/json" -d '{"amount": 25.50, "category": "food", "description": "Lunch"}'
You should see a JSON response with your expense, including a generated id and today's date.
- Confirm the expense persisted by running the GET request again:
curl http://localhost:3000/api/expenses
You should now see an array containing the expense you just created.
POST returning a 400 error?
- Make sure amount is a number (not a string like "25.50"). Zod rejects strings where numbers are expected.
- Check that your Content-Type header is set to application/json. Without it, Express cannot parse the body.
- Confirm category is not an empty string. The schema requires at least 1 character.
Help me figure out why my POST request to /api/expenses is returning a 400 error.
Add update and delete routes
A full REST API needs all four operations. PUT lets you update an existing expense, and DELETE removes one entirely.
- In src/routes/expenses.js, find the line module.exports = router; and add the PUT route directly above it:
// UPDATE an expense by ID
router.put("/:id", (req, res) => {
const index = expenses.findIndex((e) => e.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: "Expense not found." });
}
const result = expenseSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
expenses[index] = { ...expenses[index], ...result.data };
res.json(expenses[index]);
});
What does this code do?
The /:id is a route parameter. Express captures whatever value appears in that URL segment and stores it in req.params.id.
The route searches the expenses array for a matching ID. If none exists, it returns 404. Otherwise it validates the new data, merges it with the existing expense, and returns the updated version.
- Still in src/routes/expenses.js, add the DELETE route above module.exports = router;:
// DELETE an expense by ID
router.delete("/:id", (req, res) => {
const index = expenses.findIndex((e) => e.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: "Expense not found." });
}
const deleted = expenses.splice(index, 1)[0];
res.json(deleted);
});
What does this code do?
The splice method removes the expense from the array and returns it. The response sends back the deleted expense so the client can confirm what was removed.
- Save src/routes/expenses.js.
- Stop the server with Ctrl+C and restart it:
node src/index.js
Test all four CRUD operations with curl
Time to run through every operation end to end. You will create, read, update, and delete an expense from your terminal.
- In your second terminal, create a fresh expense by running:
curl -X POST http://localhost:3000/api/expenses -H "Content-Type: application/json" -d '{"amount": 42, "category": "transport", "description": "Taxi to airport"}'
You should see a JSON response with the new expense and its id. Copy that id value. You will need it for the next two commands.
- Confirm it was stored by running GET:
curl http://localhost:3000/api/expenses
You should see your expense in the returned array.
Before you run the update, what do you think will happen if you send new data to the PUT route with your expense's ID?
- Update the expense by running this command, replacing the ID with the one you copied:
curl -X PUT http://localhost:3000/api/expenses/[[EXPENSE_ID="your expense id from the POST response"]] -H "Content-Type: application/json" -d '{"amount": 55, "category": "transport", "description": "Taxi plus tip"}'
You should see the expense returned with the updated amount and description. The id stays the same.
- Delete the expense by running:
curl -X DELETE http://localhost:3000/api/expenses/[[EXPENSE_ID="your expense id from the POST response"]]
You should see the deleted expense returned one last time.
- Verify the array is now empty by running GET one more time:
curl http://localhost:3000/api/expenses
You should see [] again. All four CRUD operations work.
PUT or DELETE returning 404?
- The in-memory array resets every time you restart the server. If you restarted between creating the expense and updating or deleting it, the expense no longer exists. Run the POST again to create a new one.
- Make sure you copied the exact id from the POST response. It is a numeric string like 1752505200000.
Help me troubleshoot why my PUT or DELETE request returns 404 even though I created the expense.
✔️ Awesome, I've got everything!
Great. Double check you saved src/routes/expenses.js and src/index.js.
ⓧ I'd like to double check the full code
Here is what src/index.js should look like now:
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const expenseRoutes = require("./routes/expenses");
const app = express();
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Expense Tracker API is running" });
});
app.use("/api/expenses", expenseRoutes);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
For src/routes/expenses.js, confirm you have each of these sections in order:
- Imports (express and zod).
- Router creation and expenses array.
- The expenseSchema with amount, category, description, and date.
- GET / route.
- POST / route with safeParse validation.
- PUT /:id route.
- DELETE /:id route.
- The module.exports = router; line at the very end.
You now have a fully working expense API with validation. Next up, you will discover why this is a security problem.
Add Authentication
Right now, your expense API has no concept of identity. Anyone who finds the URL can read, modify, and delete every single expense.
In this step, you will experience that problem firsthand, then solve it by building registration and login routes that issue JWT tokens. By the end, your API can prove who a user is before handing over any data.
In this step, get ready to:
- Observe the privacy gap in your unprotected expense routes.
- Build registration and login endpoints that hash passwords and return JWT tokens.
- Test authentication by registering and logging in with curl.
Experience the problem firsthand
Your server should still be running from the previous step. If not, start it again with node src/index.js.
- Add an expense pretending to be Alice by running this command in a separate terminal:
curl -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 12.50, "category": "lunch", "description": "Alice sandwich"}'
- Add another expense pretending to be Bob:
curl -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 45.00, "category": "transport", "description": "Bob taxi"}'
Before you run the next command, think about this: if you were Bob, should you be able to see Alice's lunch expense?
- Request all expenses:
curl http://localhost:3000/api/expenses
You see both expenses in a single list. There is no privacy whatsoever.
Why is this a problem?
Any person (or bot) who discovers your API URL can read everyone's financial data. They can also delete or modify it. This is the exact gap that authentication closes: proving who is making the request before returning any data.
Build the auth routes: register and login
You will create a new file that handles user registration and login. Each endpoint will return a signed JWT token that proves the user's identity.
- Create a new file at src/routes/auth.js and add the following setup code:
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { z } = require("zod");
const router = express.Router();
// In-memory user storage (resets on server restart)
const users = [];
// Validate that email is valid and password is at least 6 characters
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
module.exports = router;
What does this code do?
This sets up the building blocks for authentication. The users array stores registered accounts in memory (just like your expenses array).
The registerSchema uses Zod to reject invalid emails and passwords shorter than 6 characters before they ever reach your logic.
- Save src/routes/auth.js.
Now you need to tell your main server file about this new route.
- In src/index.js, find this line near the top:
const expenseRoutes = require("./routes/expenses");
- Add this line directly above it:
const authRoutes = require("./routes/auth");
- Then find this line further down:
app.use("/api/expenses", expenseRoutes);
- Add this line directly above it:
app.use("/api/auth", authRoutes);
- Save src/index.js.
- Stop your running server with Ctrl+C, then restart it:
node src/index.js
You should see Server running on port 3000 printed with no errors. This confirms your new auth file loads correctly.
Seeing an error on startup?
Check that src/routes/auth.js ends with module.exports = router; and that the path in src/index.js is exactly ./routes/auth.
help me fix my auth route import error
Now add the register endpoint. This is where users create an account by sending their email and password.
- In src/routes/auth.js, find the line module.exports = router; and paste the following code directly above it:
// Register a new user account
router.post("/register", async (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const { email, password } = result.data;
const existingUser = users.find((u) => u.email === email);
if (existingUser) {
return res.status(400).json({ error: "User already exists." });
}
// Hash the password so it's never stored in plain text
const hashedPassword = await bcrypt.hash(password, 10);
const user = { id: Date.now().toString(), email, password: hashedPassword };
users.push(user);
// Sign a token that proves this user's identity for 7 days
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.status(201).json({ token, user: { id: user.id, email: user.email } });
});
What does this code do?
- bcrypt.hash(password, 10) hashes the password with 10 salt rounds, making it unreadable even if someone accesses the users array.
- jwt.sign() creates a signed token containing the user's ID and email. This token is the "proof of identity" the client sends with future requests.
- The response returns both the token and a safe user object (no password).
- Save src/routes/auth.js.
- Restart your server (Ctrl+C, then node src/index.js).
- Test registration by running this in your other terminal:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "secret123"}'
You should see a JSON response containing a token field (a long string of characters) and a user object with an id and email.
Getting a 404 or connection error?
Make sure your server restarted successfully and shows Server running on port 3000. Also verify that src/index.js contains app.use("/api/auth", authRoutes); before the expenses line.
help me debug my register endpoint
Now add the login endpoint so returning users can sign in.
- In src/routes/auth.js, find the line module.exports = router; again and paste the following code directly above it:
// Log in an existing user
router.post("/login", async (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const { email, password } = result.data;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid email or password." });
}
// Compare the plain-text 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." });
}
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({ token, user: { id: user.id, email: user.email } });
});
What does this code do?
- bcrypt.compare() checks the submitted password against the stored hash without ever reversing the hash.
- If the email does not exist or the password is wrong, the response is the same generic 401 message. This prevents attackers from discovering which emails are registered.
- On success, a fresh JWT token is signed and returned, just like registration.
- Save src/routes/auth.js.
Why use the same error message for both cases?
Returning "Invalid email or password" for both a missing user and a wrong password is a deliberate security choice. If the message said "User not found" versus "Wrong password", an attacker could enumerate valid email addresses by observing which error they get.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/routes/auth.js and src/index.js.
ⓧ I'd like to double check the full code
Here is the full src/routes/auth.js file. Make sure your code matches exactly:
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { z } = require("zod");
const router = express.Router();
const users = [];
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
router.post("/register", async (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const { email, password } = result.data;
const existingUser = users.find((u) => u.email === email);
if (existingUser) {
return res.status(400).json({ error: "User already exists." });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = { id: Date.now().toString(), email, password: hashedPassword };
users.push(user);
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.status(201).json({ token, user: { id: user.id, email: user.email } });
});
router.post("/login", async (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const { email, password } = result.data;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid email or password." });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: "Invalid email or password." });
}
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({ token, user: { id: user.id, email: user.email } });
});
module.exports = router;
And here is the updated src/index.js:
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const authRoutes = require("./routes/auth");
const expenseRoutes = require("./routes/expenses");
const app = express();
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Expense Tracker API is running" });
});
app.use("/api/auth", authRoutes);
app.use("/api/expenses", expenseRoutes);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Test registration and login
Your auth routes are built. Time to prove they work end-to-end.
- Restart your server (Ctrl+C, then node src/index.js).
- Register a fresh user by running:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "password": "mypass123"}'
You should see a JSON response with a token and a user object. The token is a long string of three parts separated by dots.
What is a JWT token?
A JWT has three dot-separated sections: a header, a payload, and a signature. The payload contains the data you signed (user ID and email). The signature proves the token has not been tampered with.
You can paste your token into jwt.io to see the decoded payload. You will see your user's id and email inside.
What do you think will happen if you try to log in with the credentials you just registered?
- Log in with the same credentials:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "password": "mypass123"}'
You receive a fresh JWT token. Notice the token string is different from the one you got at registration, but the decoded payload contains the same user ID and email.
- Verify that a wrong password is rejected:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "bob@example.com", "password": "wrongpassword"}'
You should see a 401 response with Invalid email or password. confirming that bcrypt correctly rejected the bad credentials.
Getting unexpected results?
If registration returns a validation error, check that your JSON body has both email (a valid email format) and password (at least 6 characters).
If login always returns 401, remember that the in-memory users array resets every time you restart the server. Register first, then log in without restarting between the two commands.
help me debug my auth register and login endpoints
Your API now knows how to create accounts and verify identities. Next up, you will use these JWT tokens to lock down your expense routes so each user can only see their own data.
Lock Down Your API
You've built auth routes that let users register and log in. But your expense routes are still wide open. Anyone who finds the URL can read and modify all expenses without proving their identity.
This step adds the missing piece: authorization. You'll create middleware that checks every incoming request for a valid JWT token, then scope all expense data to the authenticated user.
In this step, get ready to:
- Create a middleware function that verifies JWT tokens on incoming requests.
- Apply the middleware to all expense routes and scope data to each user.
- Test that two users' expenses are completely isolated from each other.
Create auth middleware
Middleware is a function that runs between receiving a request and reaching your route handler. It can inspect the request, reject it, or modify it before passing it along.
Your auth middleware will do three things: read the token from the request header, verify it is valid, and attach the user's identity to the request so route handlers know who is asking.
- In VS Code, create a new file at src/middleware/auth.js and paste the following code:
const jwt = require("jsonwebtoken");
function authenticate(req, res, next) {
// Read the Authorization header from the request
const authHeader = req.headers["authorization"];
if (!authHeader) {
return res.status(401).json({ error: "Access denied. No token provided." });
}
// Extract the token after "Bearer "
const token = authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Access denied. No token provided." });
}
try {
// Verify the token and attach user data to the request
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: "Invalid token." });
}
}
module.exports = authenticate;
What does this code do?
This function acts as a gatekeeper for any route it protects.
- It looks for an Authorization header in the format Bearer <token>.
- jwt.verify checks the token's signature against your JWT_SECRET. If someone tampered with the token, verification fails.
- On success, the decoded payload (user ID and email) is attached to req.user so downstream route handlers can identify who made the request.
- next() passes control to the next function in the chain. Without it, the request hangs forever.
- Save src/middleware/auth.js.
File not saving?
Make sure the src/middleware/ directory exists. If VS Code shows an error, create the folder first, then create the file inside it.
help me create the middleware folder and file
Protect expense routes and scope to the current user
Now you'll wire the middleware into your expense routes. One line of code locks every route behind token verification. Then you'll update each handler so expenses belong to a specific user.
- Open src/routes/expenses.js in VS Code.
- Replace the entire file contents with the following code, starting with the imports and setup:
const express = require("express");
const { z } = require("zod");
// Import the auth middleware you just created
const authenticate = require("../middleware/auth");
const router = express.Router();
const expenses = [];
const expenseSchema = z.object({
amount: z.number().positive(),
category: z.string().min(1),
description: z.string().optional().default(""),
date: z.string().optional().default(() => new Date().toISOString().split("T")[0]),
});
// Apply auth middleware to ALL routes in this router
router.use(authenticate);
What changed here?
- Line 3 imports the authenticate function from your new middleware file.
- router.use(authenticate) applies that function to every route defined on this router. Any request to /api/expenses now requires a valid token.
- Save src/routes/expenses.js and restart your server by pressing Ctrl+C in the terminal, then running:
node src/index.js
Before you test, what do you think will happen if you hit the expenses endpoint without a token now?
- Open a new terminal and test the middleware by running:
curl http://localhost:3000/api/expenses
You should see a 401 response with the message "Access denied. No token provided." Your middleware is blocking unauthenticated requests.
Still seeing an empty array instead of 401?
Make sure you saved src/routes/expenses.js and restarted the server. The old code is still running until you restart.
Also confirm that router.use(authenticate) appears BEFORE your route definitions, not after them. Middleware only applies to routes defined below it.
help me debug why my auth middleware isn't blocking requests
Now add the route handlers that scope expenses to the authenticated user. Add this code directly below the router.use(authenticate) line:
router.get("/", (req, res) => {
// Only return expenses belonging to the authenticated user
const userExpenses = expenses.filter((e) => e.userId === req.user.id);
res.json(userExpenses);
});
router.post("/", (req, res) => {
const result = expenseSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
const expense = {
id: Date.now().toString(),
// Tag this expense with the authenticated user's ID
userId: req.user.id,
...result.data,
};
expenses.push(expense);
res.status(201).json(expense);
});
What does this code do?
- The GET route now filters the expenses array so each user only sees their own data. The req.user.id comes from the decoded JWT token.
- The POST route stamps every new expense with userId: req.user.id so it's permanently linked to whoever created it.
- Continue adding the PUT route directly below the POST route:
router.put("/:id", (req, res) => {
// Find expense matching BOTH the ID and the current user
const index = expenses.findIndex(
(e) => e.id === req.params.id && e.userId === req.user.id
);
if (index === -1) {
return res.status(404).json({ error: "Expense not found." });
}
const result = expenseSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
expenses[index] = { ...expenses[index], ...result.data };
res.json(expenses[index]);
});
What changed in PUT?
The findIndex now checks e.userId === req.user.id alongside the expense ID. A user can only update their own expenses. If they try to modify someone else's expense, the lookup returns -1 and they get a 404 response.
- Add the DELETE route and the module export below the PUT route:
router.delete("/:id", (req, res) => {
// Only allow deleting expenses that belong to the current user
const index = expenses.findIndex(
(e) => e.id === req.params.id && e.userId === req.user.id
);
if (index === -1) {
return res.status(404).json({ error: "Expense not found." });
}
const deleted = expenses.splice(index, 1)[0];
res.json(deleted);
});
module.exports = router;
What does DELETE do differently?
Same ownership check as PUT. The splice method removes the expense from the array and returns it so the user sees what was deleted. Without the userId filter, anyone could delete anyone else's expenses.
- Save src/routes/expenses.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/routes/expenses.js before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your src/routes/expenses.js contains these sections in order:
- Imports (express, zod, authenticate) and router setup.
- The expenses array and expenseSchema definition.
- router.use(authenticate) applying middleware to all routes.
- GET / filtering by req.user.id.
- POST / storing userId: req.user.id on each expense.
- PUT /:id and DELETE /:id matching by both id AND userId.
- module.exports = router at the end.
Also confirm src/middleware/auth.js has the full authenticate function that checks the Authorization header, verifies the token, and attaches req.user.
Test the full secure flow
Time to prove that your API actually isolates user data. You'll register two separate users, add expenses for each, and confirm that neither can see the other's data.
- Restart your server by pressing Ctrl+C and running:
node src/index.js
- Register user A by running:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"alice@test.com","password":"password123"}'
You'll get back a JSON response with a token field. Copy that token value (the long string, without quotes).
- Add an expense as user A by running this command, replacing TOKEN_A with the token you just copied:
curl -X POST http://localhost:3000/api/expenses -H "Content-Type: application/json" -H "Authorization: Bearer TOKEN_A" -d '{"amount":25.50,"category":"food","description":"Lunch"}'
You should see the expense returned with your user's ID attached.
- Now register user B by running:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"bob@test.com","password":"password456"}'
- Copy user B's token and add an expense as user B:
curl -X POST http://localhost:3000/api/expenses -H "Content-Type: application/json" -H "Authorization: Bearer TOKEN_B" -d '{"amount":40.00,"category":"transport","description":"Taxi"}'
Now the real test. What do you think user B will see when they list their expenses? Will they see Alice's lunch?
- List user B's expenses by running:
curl http://localhost:3000/api/expenses -H "Authorization: Bearer TOKEN_B"
You should see only the taxi expense. Alice's lunch is completely invisible to Bob.
- Verify user A's expenses are also isolated by running:
curl http://localhost:3000/api/expenses -H "Authorization: Bearer TOKEN_A"
You should see only the lunch expense. Each user's data is completely scoped to their own identity.
- Finally, confirm that unauthenticated requests are blocked:
curl http://localhost:3000/api/expenses
You should see {"error":"Access denied. No token provided."} with a 401 status. Your API is locked down.
Seeing someone else's expenses?
Make sure your GET route filters by e.userId === req.user.id. If you see all expenses regardless of user, the filter is missing or userId wasn't stored on the expense in the POST route.
Also confirm you restarted the server after saving your changes. Old code still runs until you restart.
help me figure out why user isolation isn't working in my expense routes
Your API is now fully secured. Users must prove their identity to access anything, and their data is invisible to everyone else. Next up, you'll deploy this API to the cloud so you can hit it from anywhere in the world.
Deploy to Render
Your API is secure and working locally. The problem is that nobody else can reach it. An API running on localhost is invisible to the outside world.
Deploying to Render gives your API a public URL that anyone can hit from anywhere. This proves your project works in a real cloud environment and gives you a live endpoint you can share on a resume.
In this step, get ready to:
- Push your code to a GitHub repository.
- Deploy your API to Render's free tier.
- Test your live API from your local terminal.
Prepare for deployment
Your project is already deployment-ready. The start script in package.json points to node src/index.js. Your server reads the port from process.env.PORT (which Render sets automatically). Your .gitignore already excludes node_modules and .env.
The only remaining task is pushing your code to GitHub so Render can pull it down and deploy it.
- Initialize a Git repository and make your first commit by running these commands in your terminal:
git init
git add .
git commit -m "Initial commit"
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all your project files for the commit.
- git commit -m "Initial commit" saves a snapshot of your code with a message.
- Confirm the commit was saved by running:
git log --oneline
You should see one line showing your commit hash and the message Initial commit.
Now you need a GitHub repository to push this code to.
- Open your browser and go to github.com/new.
- Enter expense-tracker-api as the repository name.
- Leave the repository set to Public.
- Do NOT check any boxes for README, .gitignore, or license (you already have your own files).
- Click Create repository.
GitHub shows a page with setup commands. You only need the two lines that connect your local repo to GitHub.
- Push your code to GitHub by running these commands:
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/expense-tracker-api.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin links your local repo to the GitHub repository you just created.
- git branch -M main renames your default branch to main.
- git push -u origin main uploads your code to GitHub.
- Refresh your GitHub repository page in the browser.
You should see all your project files listed: src/, package.json, .gitignore. Your .env file should NOT appear (it's excluded by .gitignore).
Push failed or asking for credentials?
If Git asks for a username and password, GitHub no longer accepts passwords for push. You need a Personal Access Token or SSH key. GitHub shows setup instructions on the repository page after creation.
If you see a "remote already exists" error, you may have already added a remote. Run git remote -v to check, then git remote remove origin and try again.
help me push my code to GitHub
Deploy on Render
Your code is on GitHub. Now you'll create a Render account and deploy directly from your repository.
- Open render.com in your browser.
- Click Start for free.
- Choose to sign up with your GitHub account. Authorize Render when prompted.
Why sign up with GitHub?
Linking your GitHub account during sign-up lets Render access your repositories automatically. This means you can deploy in a few clicks without any extra configuration.
Once you're in the Render Dashboard, create your web service.
- Click + New in the top-right corner of the dashboard.
- Select Web Service from the dropdown menu.
- Find your expense-tracker-api repository in the list and click Connect.
The service creation form appears. Fill in the deployment settings.
- Enter a name for your service in the Name field (this becomes part of your URL, e.g. expense-tracker-api).
- Set the Language to Node.
- Set Build Command to npm install.
- Set Start Command to npm start.
- Select the Free instance type.
Before you deploy, you need to add your JWT secret as an environment variable. Render cannot read your local .env file.
- Scroll down and click Advanced to expand additional settings.
- Under environment variables, click Add Environment Variable.
- Enter JWT_SECRET as the key.
- Enter a strong secret value of your choice as the value (e.g. a long random string of letters and numbers).
- Click Deploy at the bottom of the form.
Why set JWT_SECRET here?
Your .env file is excluded from Git (for good reason). Render needs its own copy of the secret to verify tokens. Setting it as an environment variable in the dashboard keeps it secure and separate from your code.
Render begins building and deploying your service. You can watch the logs on the service's page in the dashboard.
- Wait for the deploy status to change to Live. This typically takes 1-2 minutes.
Once you see Live, your API is running in the cloud. Render shows your service's URL at the top of the page (something like https://expense-tracker-api-xxxx.onrender.com).
Deploy failed?
Check the build logs on your service's page. Common issues:
- If you see a dependency error, make sure your package.json was pushed to GitHub (check the repo page).
- If the service crashes on start, verify that JWT_SECRET is set in your Render environment variables.
- If you see "port already in use", make sure your code uses process.env.PORT || 3000 (not a hardcoded port).
help me debug my Render deployment
Test your live API
Your API is deployed. Before you trust it, let's confirm it actually works from outside your machine.
- Copy your Render URL from the top of your service's dashboard page.
- Test the welcome endpoint by running this command (replace the URL with yours):
curl [[RENDER_URL="https://your-app-name.onrender.com"]]/
You should see:
{"message":"Expense Tracker API is running"}
First request takes a moment?
Free Render services spin down after 15 minutes of inactivity. The first request wakes the service up, which can take up to a minute. Subsequent requests will be fast.
Now for the real test. Before you run this next command, predict: do you think registration will succeed on the live server, or will something fail?
- Register a user on your live API by running:
curl -X POST [[RENDER_URL="https://your-app-name.onrender.com"]]/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "secret123"}'
You should receive a JSON response containing a token field and a user object with your email. Your live API just created a user and signed a JWT token in the cloud.
- Verify that auth protection works by trying to access expenses without a token:
curl [[RENDER_URL="https://your-app-name.onrender.com"]]/api/expenses
You should see {"error":"Access denied. No token provided."} with a 401 status. Your middleware is protecting the live deployment exactly like it did locally.
Getting a different error from your live API?
- If you see a "Cannot POST" or 404 error, double-check that your Render URL is correct and includes the full path (/api/auth/register).
- If you get an empty response or timeout, your service might still be waking up. Wait 60 seconds and try again.
- If registration returns a 500 error, check your Render logs. The JWT_SECRET environment variable might be missing.
help me troubleshoot my live API responses
Your API is live, secured, and accessible from anywhere in the world. Anyone with the URL can register, log in, and manage their own private expenses.
Secret mission
Add Spending Analytics
Your API stores expenses, but it can't answer the question that actually matters: where is your money going? Build a summary endpoint that calculates totals by category and monthly breakdowns.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on Render's free tier, so there are no ongoing costs while your service is idle.
Resources you used:
- Render Web Service (free tier, spins down after 15 minutes of inactivity).
- GitHub repository containing your project code.
- Local project folder on your Desktop (expense-tracker-api).
Keep everything running
No action needed. Choose this if you plan to keep building on this API or want a live URL to demo.
- Your Render service stays live indefinitely on the free tier. It spins down after 15 minutes of inactivity and wakes on the next request (takes about 1 minute).
- Your GitHub repo stays available for reference or future development.
- Your local project folder remains on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up local memory. Your cloud resources stay intact since they cost nothing while idle.
- Stop your local server if it is still running by pressing Ctrl+C in the terminal.
- Your Render service already spins down automatically when idle. No action needed there.
- Your GitHub repo and local files stay as-is for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete your Render Web Service: open the Render Dashboard, click on your service, click Settings in the left sidebar, scroll to the bottom, and click Delete Web Service. Type the service name to confirm.
- Delete your GitHub repository: open the repository page on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Type the repository name to confirm.
- Delete your local project folder by running this command in your terminal:
rm -rf ~/Desktop/expense-tracker-api
On Windows, use this command instead:
Remove-Item -Recurse -Force ~\Desktop\expense-tracker-api
Nice Work!
Nice Work!
You just built and deployed a secure REST API that handles real user accounts and financial data. Your expense tracker is live on the internet, protected by the same authentication patterns used in production applications.
Here's what you learned:
- Built a full REST API from scratch with Express that handles user registration, login, and complete CRUD operations for expenses.
- Implemented JWT-based authentication with secure password hashing using bcrypt, and created middleware that protects routes and scopes data to the authenticated user.
- Deployed a live API to Render with environment variables, input validation via Zod, and a public URL you can hit from anywhere in the world.
- Secret Mission: Built an analytics endpoint that aggregates spending by category and month, transforming raw expense data into actionable insights.
Ready to quiz yourself?