Add Auth and Deploy Your Expense Tracker

Add JWT authentication and deploy your MERN expense tracker to Render.

Introduction

30 Second Summary

Imagine sharing a budgeting app with a friend, only to discover they can see all your spending. Without user accounts and login, there is no way to keep data private.

In this project, you will add authentication to an existing expense tracker so each user only sees their own data. Then you will deploy the full-stack app to the internet with a live public URL.

What You'll Build

You will register a new account, log in, add a few expenses, then hand the URL to a friend who logs in with their own account and sees a completely empty list.

By the end of this project, you'll have:

  • A login and register system that authenticates users with hashed passwords and JWT tokens.
  • Expense data scoped per user so each person only sees their own expenses when they log in.
  • A deployed full-stack app on Render with a public URL you can share with anyone.
  • Secret Mission: Add the ability to edit existing expenses with an inline edit form.

Are there any prerequisites?

This project builds on a working MERN expense tracker running locally. You should already have a MongoDB Atlas account, a GitHub account, and a Vite-based React frontend connected to an Express backend.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Your expense tracker works locally, but it has no concept of users. Anyone who opens it sees the same list of expenses. By the end of this project, you'll have real authentication, user-scoped data, and a live public URL.

Install Auth Packages

Your expense tracker works, but it has a blind spot: there is no concept of users. Anyone who opens the app sees the exact same list of expenses.

Before you can build login and registration, your project needs authentication libraries. In this step, you will install everything the backend and frontend need to support user accounts.

In this step, get ready to:
  • Install password hashing and token libraries in the backend.
  • Install React Router in the frontend for login and register pages.
  • Add a secret key for signing authentication tokens.
Install backend auth packages

Your backend needs two new libraries. bcryptjs hashes passwords so you never store them in plain text. jsonwebtoken creates signed tokens that prove a user is logged in.

  • In your VS Code terminal, navigate to your backend folder and install both packages by running these commands:
cd server
npm install bcryptjs@3.0.3 jsonwebtoken@9.0.3

What do these packages do?

  • bcryptjs is a pure JavaScript implementation of the bcrypt hashing algorithm. It turns a plain-text password into an irreversible hash that is safe to store in a database.
  • jsonwebtoken creates and verifies JWT tokens. A JWT is a small signed string that your server hands to a logged-in user so they can prove their identity on future requests.

You should see terminal output confirming both packages were added, with lines showing their version numbers.

Seeing install errors?

  • Make sure you are inside the server folder, not the project root. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
  • If you see a network timeout, check your internet connection and try running the install command again.

help me fix an npm install error in my backend folder

Install React Router in the frontend

Your frontend needs page routing so users can navigate between login, register, and expense screens. React Router handles this by mapping URLs to components.

  • Navigate to your frontend folder and install React Router by running these commands:
cd ../client
npm install react-router-dom@7.18.1

Why react-router-dom?

react-router-dom is the browser-specific version of React Router. It gives you components like BrowserRouter, Routes, and Navigate that map URL paths to your page components.

You should see terminal output confirming the package was added successfully.

Install failing for react-router-dom?

  • Confirm you are inside the client folder. If you skipped the previous cd ../client command, you may still be in the server folder.
  • If you see a peer dependency warning about React version, it is safe to ignore as long as the install completes without an error code.

help me fix a react-router-dom install issue

Add JWT_SECRET to your .env file

When your server creates a JWT token, it signs it with a secret key. Without this key, the server cannot verify that a token is genuine.

  • In VS Code, open the .env file inside your server folder.
  • Add the following line below your existing MONGO_URI value:
JWT_SECRET=myexpensetracker2026secretkey

What is JWT_SECRET for?

This string is the password your server uses to sign and verify tokens. Anyone with this secret could forge valid tokens, so it must stay private. In a production app, you would use a long random string and never commit it to version control.

  • Save the .env file.
  • Confirm the file now has both MONGO_URI and JWT_SECRET on separate lines.

Before you run the verification command, what do you expect to see when you list the installed backend packages?

  • Navigate back to your server folder and verify your backend packages by running these commands:
cd ../server
npm list bcryptjs jsonwebtoken

You should see both bcryptjs@3.0.3 and jsonwebtoken@9.0.3 listed in the output tree without any errors.

Packages not showing up?

  • If you see (empty) or a missing package, re-run the install command from inside the server folder.
  • Check that your server/package.json has both packages listed under dependencies. If not, the install did not complete successfully.

help me figure out why npm list is not showing my packages

All three packages are installed and your secret key is ready. Next up, you will use these libraries to build registration and login endpoints that create real user accounts.

Build the Authentication API

Your expense tracker currently has no concept of who is using it. Anyone who sends a request to the API sees the same data, because there's no way to identify one user from another.

In this step, you'll build register and login endpoints that create user accounts and hand back a token proving the user's identity. By the end, you'll be able to register a brand new user via curl and see a token come back in the response.

In this step, get ready to:
  • Create a User model for storing accounts in MongoDB.
  • Build register and login API endpoints that return JWT tokens.
  • Wire the new auth routes into your server and test registration with curl.
Create the User model

Before your app can register or log in users, it needs a place to store their accounts. A Mongoose model defines the shape of a user document in your MongoDB database.

  • In your server folder, create a new file at models/User.js by running this command:
touch models/User.js
  • Add the User schema to models/User.js by pasting this code:
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    required: true,
    minlength: 6
  }
}, { timestamps: true });

module.exports = mongoose.model('User', userSchema);

What does this code do?

  • email is marked unique so no two accounts can share the same address.
  • lowercase and trim normalize the email before saving, preventing duplicates like Test@Email.com vs test@email.com.
  • password requires at least 6 characters.
  • timestamps: true automatically adds createdAt and updatedAt fields to every user document.
  • Save models/User.js.
  • Confirm the file exists by running:
ls models/

You should see User.js listed in the output.

Don't see User.js?

  • Make sure you ran the touch command from your server folder, not from the project root.
  • Check that a models folder exists. If not, create it with mkdir models first.
  • Help me create the User model file in the right location.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true
  },
  password: {
    type: String,
    required: true,
    minlength: 6
  }
}, { timestamps: true });

module.exports = mongoose.model('User', userSchema);
Create the auth routes

With the User model ready, you need two endpoints: one for creating new accounts and one for logging in. Both return a JWT token that proves the user's identity for future requests.

  • Create a new file at routes/auth.js by running:
touch routes/auth.js
  • Add the imports and the register route to routes/auth.js by pasting this code:
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const User = require('../models/User');

// Register a new user
router.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Check if account already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }

    // Hash the password before storing it
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // Save the new user
    const user = new User({ email, password: hashedPassword });
    await user.save();

    // Return a signed token
    const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' });

    res.status(201).json({ token, userId: user._id });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What does this code do?

  • The register route first checks if a user with that email already exists to prevent duplicates.
  • bcrypt.genSalt(10) generates a random salt with 10 rounds of complexity. The password is then hashed so the raw password is never stored in the database.
  • jwt.sign() creates a token containing the user's ID. The token expires after 1 hour, so users must log in again after that.
  • Save routes/auth.js.
  • Confirm the file was created by running:
ls routes/

You should see auth.js alongside your existing expenses.js.

Don't see auth.js?

  • Confirm you're in the server folder by running pwd. You should see a path ending in /server.
  • Check that the routes folder already exists (it should from your expenses routes).
  • Help me figure out why auth.js wasn't created in the right place.
  • Now add the login route below the register route in routes/auth.js. Paste this code at the bottom of the file, before the closing line:
// Log in an existing user
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Find the user by email
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    // Compare provided password with stored hash
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    // Return a signed token
    const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' });

    res.json({ token, userId: user._id });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

module.exports = router;

What does this code do?

  • The login route finds a user by email, then uses bcrypt.compare() to check the provided password against the stored hash.
  • Both "user not found" and "wrong password" return the same generic Invalid credentials message. This prevents attackers from learning which emails are registered.
  • module.exports = router makes the routes available for your server to import.
  • Save routes/auth.js.

✔️ Awesome, I've got everything!

Great. Confirm you've saved the file. The next substep wires these routes into your server so you can test them.

ⓧ I'd like to double check the full code

Your routes/auth.js is over 30 lines, so scroll up and confirm these two sections are present in order:

  • The imports block (5 lines requiring express, bcryptjs, jsonwebtoken, and User).
  • The router.post('/register', ...) handler.
  • The router.post('/login', ...) handler.
  • module.exports = router at the very end.
Wire auth routes into server.js and test

The auth routes exist as a file, but your Express server doesn't know about them yet. You need to register them in server.js so they respond to incoming requests.

  • Open server/server.js.
  • Find this line:
app.use('/api/expenses', require('./routes/expenses'));
  • Add the auth route directly above it, so both lines look like this:
app.use('/api/auth', require('./routes/auth'));
app.use('/api/expenses', require('./routes/expenses'));
  • Save server.js.
  • Start your backend server by running:
node server.js

You should see Server running on port 5000 in your terminal, confirming the server started without errors.

Seeing an error on startup?

  • If you see Cannot find module './routes/auth', double check that routes/auth.js exists and the filename matches exactly (case-sensitive).
  • If you see a syntax error, open routes/auth.js and check for missing closing brackets or parentheses.
  • Help me fix the error I'm seeing when starting my server.

Now test the register endpoint. Before you run the next command, what do you think will come back? A token, or an error?

  • Open a second terminal tab (keep the server running in the first one).
  • Test registration by running this curl command from your server folder:
curl -X POST http://localhost:5000/api/auth/register -H "Content-Type: application/json" -d "{\"email\":\"test@example.com\",\"password\":\"test123\"}"

You should see a JSON response containing a token string and a userId. That token is proof that registration worked and the user was saved to your database.

Getting an error from curl?

  • If you see Connection refused, make sure your server is still running in the other terminal tab.
  • If you see User already exists, that means you ran the command twice. This is actually a success. Your duplicate-check logic is working.
  • On Windows, replace single quotes in the curl command with escaped double quotes, or use PowerShell's Invoke-RestMethod.
  • Help me troubleshoot my curl request to the register endpoint.

✔️ Awesome, I've got everything!

Great. Make sure you've saved server.js and that the server is still running.

ⓧ I'd like to double check the full code

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();

const app = express();

app.use(cors());
app.use(express.json());

app.use('/api/auth', require('./routes/auth'));
app.use('/api/expenses', require('./routes/expenses'));

const PORT = process.env.PORT || 5000;

mongoose.connect(process.env.MONGO_URI)
  .then(() => {
    app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
  })
  .catch((error) => console.log(error));

Your authentication API is live. You can now create accounts and get tokens back. Next up, you'll build the Login and Register pages so users can do this from a browser instead of curl.

Build Login and Register Pages

Your authentication API works, but right now the only way to use it is through curl commands in the terminal. Real users need a visual interface to create accounts and sign in.

In this step, you will build Login and Register pages in React, wire up client-side routing so users land on the correct page, and move your expense tracker into a protected Home page with a Logout button.

In this step, get ready to:
  • Create Login and Register page components that talk to your auth API.
  • Set up React Router so users navigate between pages.
  • Add a logout button to the expense tracker page.
Create Login and Register page components

Each auth page is its own React component with a form that POSTs to your backend. On success, the returned token gets stored in localStorage and the user is redirected to the home page.

  • Create a pages folder inside your client/src directory by running this command:
mkdir src/pages
  • Create a new file called Login.jsx inside src/pages and start with the following imports, state setup, and form handler:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';

function Login({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');

    try {
      const response = await fetch(`${API_URL}/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.message || 'Login failed');
        return;
      }
  • Directly below that code in the same file, add the token storage, redirect, and the component's JSX:
      localStorage.setItem('token', data.token);
      setToken(data.token);
      navigate('/');
    } catch (err) {
      setError('Something went wrong. Please try again.');
    }
  };

  return (
    <div className="auth-container">
      <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 the Login component do?

  • API_URL reads from an environment variable so the same code works locally and in production.
  • handleSubmit sends the email and password to your /api/auth/login endpoint. On success it saves the token to localStorage and calls setToken so the app knows the user is logged in.
  • If the response is not OK, the error message from the server is displayed above the form.
  • Save Login.jsx.
  • Now create a second file called Register.jsx inside src/pages and add the following imports, state, and handler:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';

function Register({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [error, setError] = useState('');
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');

    if (password !== confirmPassword) {
      setError('Passwords do not match');
      return;
    }

    try {
      const response = await fetch(`${API_URL}/api/auth/register`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      const data = await response.json();
  • Directly below that code in Register.jsx, add the success handling and the form JSX:
      if (!response.ok) {
        setError(data.message || 'Registration failed');
        return;
      }

      localStorage.setItem('token', data.token);
      setToken(data.token);
      navigate('/');
    } catch (err) {
      setError('Something went wrong. Please try again.');
    }
  };

  return (
    <div className="auth-container">
      <h2>Register</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 (min 6 characters)"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          minLength={6}
        />
  • Finally, add the confirm password input, submit button, and closing tags at the bottom of Register.jsx:
        <input
          type="password"
          placeholder="Confirm Password"
          value={confirmPassword}
          onChange={(e) => setConfirmPassword(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;

How does Register differ from Login?

Register adds a confirmPassword field and checks that both passwords match before sending the request. It also enforces a minimum length of 6 characters to match your backend schema.

On success it stores the token and redirects to home, just like Login. The user is immediately signed in after registering.

  • Save Register.jsx.
  • Confirm both files exist by running this command from your client folder:
ls src/pages

You should see Login.jsx and Register.jsx listed in the output.

Don't see both files?

  • Make sure you created the pages folder inside src, not in the project root.
  • Check that each file ends with .jsx (not .js).

Help me debug missing page files in my React project.

Set up routing in App.jsx

React Router lets you map URL paths to components. You will replace your current App.jsx with a router that shows Login, Register, or Home depending on the URL and whether a token exists.

  • Open client/src/App.jsx and replace its entire contents with the following routing setup:
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Home from './pages/Home';

function App() {
  const [token, setToken] = useState(localStorage.getItem('token'));

  return (
    <BrowserRouter>
      <Routes>
        <Route
          path="/"
          element={token ? <Home token={token} setToken={setToken} /> : <Navigate to="/login" />}
        />
        <Route
          path="/login"
          element={!token ? <Login setToken={setToken} /> : <Navigate to="/" />}
        />
        <Route
          path="/register"
          element={!token ? <Register setToken={setToken} /> : <Navigate to="/" />}
        />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

What does this routing do?

  • token state is initialized from localStorage so a returning user stays logged in after a page refresh.
  • The / route shows Home only if a token exists. Otherwise it redirects to /login.
  • The /login and /register routes are only accessible when there is no token. A logged-in user trying to visit /login gets sent back to /.
  • Save App.jsx.

Seeing a blank page or import error?

The app imports Home from ./pages/Home which does not exist yet. You will create it in the next substep. The dev server may show an error until Home.jsx is in place.

Help me fix a React Router import error in my Vite app.

Add a logout button to the expense tracker page

The Home component holds your expense tracker and includes a Logout button in the header. It also sends the auth token with every API request so the backend knows which user is making the call.

  • Create src/pages/Home.jsx and start with the imports, state declarations, and the function that fetches expenses:
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';

function Home({ token, setToken }) {
  const [expenses, setExpenses] = useState([]);
  const [title, setTitle] = useState('');
  const [amount, setAmount] = useState('');
  const [category, setCategory] = useState('');
  const navigate = useNavigate();

  useEffect(() => {
    fetchExpenses();
  }, []);

  const fetchExpenses = async () => {
    try {
      const response = await fetch(`${API_URL}/api/expenses`, {
        headers: { 'Authorization': `Bearer ${token}` }
      });

      if (response.status === 401) {
        handleLogout();
        return;
      }

      const data = await response.json();
      setExpenses(data);
    } catch (error) {
      console.error('Error fetching expenses:', error);
    }
  };

Why send the token with fetch?

Every request to the expenses API includes an Authorization header with the JWT token. Without it, the backend returns a 401 and the component triggers a logout automatically.

  • Below the fetchExpenses function, add the handlers for adding expenses, deleting expenses, and logging out:
  const handleAddExpense = async (e) => {
    e.preventDefault();

    try {
      const response = await fetch(`${API_URL}/api/expenses`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${token}`
        },
        body: JSON.stringify({ title, amount: Number(amount), category })
      });

      if (response.ok) {
        const newExpense = await response.json();
        setExpenses([newExpense, ...expenses]);
        setTitle('');
        setAmount('');
        setCategory('');
      }
    } catch (error) {
      console.error('Error adding expense:', error);
    }
  };
  • Continue adding the delete handler, logout handler, and total calculation below handleAddExpense:
  const handleDelete = async (id) => {
    try {
      const response = await fetch(`${API_URL}/api/expenses/${id}`, {
        method: 'DELETE',
        headers: { 'Authorization': `Bearer ${token}` }
      });

      if (response.ok) {
        setExpenses(expenses.filter((expense) => expense._id !== id));
      }
    } catch (error) {
      console.error('Error deleting expense:', error);
    }
  };

  const handleLogout = () => {
    localStorage.removeItem('token');
    setToken(null);
    navigate('/login');
  };

  const total = expenses.reduce((sum, expense) => sum + expense.amount, 0);
  • Finally, add the return statement with the JSX that renders the header, expense form, and expense list:
  return (
    <div className="container">
      <header>
        <h1>Expense Tracker</h1>
        <button onClick={handleLogout} className="logout-btn">Logout</button>
      </header>

      <div className="total">
        <h2>Total: ${total.toFixed(2)}</h2>
      </div>

      <form onSubmit={handleAddExpense} className="expense-form">
        <input
          type="text"
          placeholder="Expense title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
        />
        <input
          type="number"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          required
        />
  • Add the final section of the JSX and the export at the bottom of Home.jsx:
        <input
          type="text"
          placeholder="Category"
          value={category}
          onChange={(e) => setCategory(e.target.value)}
        />
        <button type="submit">Add Expense</button>
      </form>

      <ul className="expense-list">
        {expenses.map((expense) => (
          <li key={expense._id}>
            <span>{expense.title} - ${expense.amount.toFixed(2)} ({expense.category})</span>
            <button onClick={() => handleDelete(expense._id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default Home;

What does the Logout button do?

handleLogout removes the token from localStorage, sets the token state to null, and navigates to /login. Because the token is now null, the router's conditional in App.jsx keeps the user on the login page until they sign in again.

  • Save Home.jsx.
  • Make sure your backend server is still running in a separate terminal (node server.js from the server folder).
  • Start your frontend dev server by running this command from the client folder:
npm run dev

Before you open the browser, what do you think you will see at http://localhost:5173? Will it show the expense tracker or something else?

  • Open http://localhost:5173/login in your browser.

You should see a login form with Email and Password fields and a Login button. Since you have no token stored, visiting / also redirects here automatically.

  • Type the email and password you registered earlier (test@example.com / test123) and click Login.

After clicking Login, you should be redirected to the expense tracker page at /. The page shows the Expense Tracker heading and a Logout button in the header.

Login not redirecting?

  • Open your browser's developer console (F12) and check for network errors. A failed fetch to localhost:5000 means your backend is not running.
  • Confirm you registered with the same email and password earlier via curl. If you used a different password, try registering a new account at /register.
  • Check that App.jsx imports Login from './pages/Login' with the correct path and capitalization.

Help me debug my Login page not redirecting after submit.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: Login.jsx, Register.jsx, Home.jsx, and App.jsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each chunk is present in the correct file, in order:

  • src/pages/Login.jsx has the imports, state, handleSubmit, and the return with the form and Register link.
  • src/pages/Register.jsx has the imports, state (including confirmPassword), handleSubmit with the password match check, and the return with three inputs and a Login link.
  • src/pages/Home.jsx has imports, state, fetchExpenses, handleAddExpense, handleDelete, handleLogout, total calculation, and the return with header, form, and expense list.
  • src/App.jsx has BrowserRouter wrapping three Routes with Navigate redirects based on token state.

Your app now has a full authentication UI. Users can register, log in, and log out. Next up, you will lock down your expense API so each user only sees their own data.

Protect Expenses by User

Your login system works. Users can register and sign in. But there is a critical problem hiding in the expense API.

Every logged-in user currently sees every expense in the database. There is nothing tying an expense to a specific user. In this step, you will build an auth middleware that verifies tokens, then lock down your expense routes so each person only sees their own data.

In this step, get ready to:
  • Create auth middleware that verifies JWT tokens on every request.
  • Add a user field to the Expense model and protect all expense routes.
  • Confirm each user only sees their own expenses.
See the problem firsthand

Before fixing anything, prove the gap exists. Your backend currently returns every expense to every user because there is no filter.

  • Open your browser to http://localhost:5173.
  • Log in with your first test account and add an expense (for example, "Coffee" for $5).
  • Click **Logout**.
  • Register a brand new account with a different email.

What do you expect to see on the new account's expense list?

You see the first account's "Coffee" expense. Both accounts share the same list because the GET route returns every expense in the collection with no filter. That is the gap we close now.

Create the auth middleware

Express middleware is a function that runs before your route handler. It can inspect the request, reject it, or attach data for the route to use.

  • Inside your server folder, create a new folder called middleware.
  • Create a file called auth.js inside server/middleware and paste the following code:
const jwt = require('jsonwebtoken');

const auth = (req, res, next) => {
  // Extract the token from the Authorization header
  const token = req.header('Authorization')?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ message: 'No token, authorization denied' });
  }

  try {
    // Verify the token and extract the user ID
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = decoded.userId;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Token is not valid' });
  }
};

module.exports = auth;

What does this code do?

This middleware sits between an incoming request and your route handler. It does three things:

  • Reads the Authorization header and strips the "Bearer " prefix to get the raw token string.
  • If no token exists, it immediately responds with 401 and never reaches your route.
  • If a token exists, jwt.verify() decodes it using your secret key and attaches the user's ID to req.userId so your route can use it.
  • Save server/middleware/auth.js.
  • Confirm the file exists by running:
ls server/middleware/

You should see auth.js listed in the output.

Don't see the middleware folder?

Make sure you created the middleware folder inside server/, not at the project root. The path should be server/middleware/auth.js.

Help me set up my auth middleware file in the right location.

Add a user field to the Expense model and protect routes

The auth middleware knows WHO is making the request. Now you need to tell the Expense model that every expense belongs to a specific user, and update the routes to enforce it.

  • Open server/models/Expense.js and replace its entire contents with the following code:
const mongoose = require('mongoose');

const expenseSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true
  },
  amount: {
    type: Number,
    required: true
  },
  category: {
    type: String,
    default: 'General'
  },
  // Links each expense to the user who created it
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  }
}, { timestamps: true });

module.exports = mongoose.model('Expense', expenseSchema);

What does the user field do?

The user field stores the ObjectId of the user who created the expense. It references the User model. With required: true, MongoDB will reject any expense that does not have an owner.

  • Save server/models/Expense.js.
  • Now open server/routes/expenses.js and replace its entire contents with the following code:
const express = require('express');
const router = express.Router();
const Expense = require('../models/Expense');
const auth = require('../middleware/auth');

router.get('/', auth, async (req, res) => {
  try {
    // Only return expenses belonging to the logged-in user
    const expenses = await Expense.find({ user: req.userId }).sort({ createdAt: -1 });
    res.json(expenses);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

router.post('/', auth, async (req, res) => {
  try {
    const { title, amount, category } = req.body;
    const expense = new Expense({
      title,
      amount,
      category,
      user: req.userId // Attach the authenticated user's ID
    });
    await expense.save();
    res.status(201).json(expense);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What changed in these routes?

  • The auth middleware is passed as the second argument to each route. It runs before the route handler.
  • The GET route now filters with { user: req.userId } so each user only receives their own expenses.
  • The POST route sets user: req.userId on every new expense, permanently linking it to the creator.
  • Directly below the POST route in the same file, add the DELETE route and the module export:
router.delete('/:id', auth, async (req, res) => {
  try {
    // Only delete if the expense belongs to this user
    const expense = await Expense.findOneAndDelete({ _id: req.params.id, user: req.userId });
    if (!expense) {
      return res.status(404).json({ message: 'Expense not found' });
    }
    res.json({ message: 'Expense deleted' });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

module.exports = router;

Why findOneAndDelete with two conditions?

Using { _id: req.params.id, user: req.userId } means a user can only delete their own expenses. Even if someone guesses another expense's ID, the query won't match because the user field won't match their token.

  • Save server/routes/expenses.js.
  • Restart your backend server by pressing Ctrl+C in the server terminal, then run:
node server.js

You should see Server running on port 5000 in the terminal.

Seeing a syntax error on startup?

Check server/routes/expenses.js for missing commas, brackets, or the module.exports = router line at the bottom. The DELETE route must sit after the POST route's closing }); and before module.exports.

Help me fix a syntax error in my expenses.js routes file.

Verify per-user scoping works

Your frontend already sends the Authorization header with every fetch call. You set this up when you built Home.jsx in the previous step. No frontend changes are needed.

Time to prove that per-user scoping works. Before you test, do you think your existing test expenses will still appear, or will they be gone?

  • Switch to your browser and log out if you are currently logged in.
  • Register a fresh account (use a new email you have not used before).
  • Add an expense, for example "Lunch" for $12.
  • Click **Logout**.
  • Register a second fresh account with a different email.
  • Add a different expense, for example "Books" for $30.

Each account now shows only its own expense. The first account sees only "Lunch" and the second sees only "Books".

Clear old test data

Expenses created before you added the user field are now orphaned. They belong to no one. You can delete them from MongoDB Atlas by opening your cluster, selecting the expenses collection, and removing documents that have no user field.

Both accounts still showing the same expenses?

  • Make sure you restarted the backend server after saving the updated expenses.js. Changes only take effect after a restart.
  • Confirm auth is imported correctly in expenses.js with const auth = require('../middleware/auth').
  • Check that your GET route filters by { user: req.userId } and not an empty {}.

Help me figure out why both accounts still see the same expenses.

✔️ Awesome, I've got everything!

Great. Double check you have saved server/middleware/auth.js, server/models/Expense.js, and server/routes/expenses.js.

ⓧ I'd like to double check the full code

Here is the full server/middleware/auth.js for reference:

const jwt = require('jsonwebtoken');

const auth = (req, res, next) => {
  const token = req.header('Authorization')?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ message: 'No token, authorization denied' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = decoded.userId;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Token is not valid' });
  }
};

module.exports = auth;

Here is the full server/models/Expense.js:

const mongoose = require('mongoose');

const expenseSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true
  },
  amount: {
    type: Number,
    required: true
  },
  category: {
    type: String,
    default: 'General'
  },
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  }
}, { timestamps: true });

module.exports = mongoose.model('Expense', expenseSchema);

Your server/routes/expenses.js should contain these sections in order. Scroll up and confirm each chunk is present:

  • Imports: express, Expense model, auth middleware.
  • GET / route with auth and Expense.find({ user: req.userId }).
  • POST / route with auth and user: req.userId set on the new expense.
  • DELETE /:id route with auth and findOneAndDelete({ _id: req.params.id, user: req.userId }).
  • module.exports = router at the bottom.

Your expenses are now locked down per user. Next up, you will deploy this entire app to the internet so anyone can register and use it from a public URL.

Deploy to Render

Your expense tracker works perfectly on your machine, but nobody else can reach it. A project running on localhost is invisible to the outside world.

Deploying gives you a public URL that anyone with a browser can visit. In this step you will push your code to GitHub and deploy both the backend and the frontend to Render, a cloud platform with a generous free tier.

In this step, get ready to:
  • Push your full-stack project to a GitHub repository.
  • Deploy the Express backend as a Render Web Service.
  • Deploy the React frontend as a Render Static Site with a public URL.
Prepare the project for deployment

Before Render can build your app, the code must live in a GitHub repository that Render can access. Your backend already uses process.env.PORT || 5000 and app.use(cors()), so no server code changes are needed.

You do need to update your MongoDB Atlas network access so the Render servers can reach your database. Render free tier IPs are not static, so you must allow connections from any IP address.

  • Open your MongoDB Atlas dashboard in a browser.
  • Click Network Access in the left sidebar under Security.
  • Click Add IP Address.
  • Click Allow Access from Anywhere (this sets the entry to 0.0.0.0/0).
  • Click Confirm.

Why allow access from anywhere?

Render's free tier does not provide static IP addresses for your backend. Each time your service spins up, it may use a different IP. Setting 0.0.0.0/0 in Atlas lets any IP connect, which works for a learning project.

In production you would use private networking or a fixed IP range instead. Your database user password still protects against unauthorized access.

Next, ensure your server/package.json has a start script so Render can run npm start.

  • Open server/package.json in VS Code.
  • Confirm the scripts section contains a start entry pointing to your server file, like this:
"scripts": {
  "start": "node server.js"
}

What does this script do?

Render runs npm start to launch your backend after building it. Without this script, the deploy will fail because Render does not know which file to execute.

Now initialize a Git repository and push everything to GitHub.

  • Create a .gitignore file in the root of your project (the folder that contains both server and client) with the following content by running:
echo "node_modules
.env" > .gitignore

Why a .gitignore?

The node_modules folder is large and reproducible from package.json. The .env file contains your database credentials and must never be committed.

  • Initialize the repo and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"
  • Go to github.com/new in your browser.
  • Enter a repository name (for example expense-tracker).
  • Leave the repo set to Public and click Create repository.
  • Copy the two commands GitHub shows under "push an existing repository from the command line" and run them in your terminal. They look like this:
git remote add origin https://github.com/[[GITHUB_USER="your-github-username"]]/[[REPO_NAME="expense-tracker"]].git
git branch -M main
git push -u origin main
  • Refresh your GitHub repository page in the browser.

You should see your server and client folders listed in the repo.

Don't see your files on GitHub?

Make sure you ran git add . before committing. If the push failed with an authentication error, you may need to create a GitHub Personal Access Token and use it as your password.

help me push my code to GitHub

Deploy the backend on Render

With your code on GitHub, you can now deploy the Express API as a Render Web Service. Render will pull your repo, run the build command, and start the server on a public URL.

  • Open dashboard.render.com and sign up or log in (you can use your GitHub account).
  • In the Render Dashboard, click + New in the top-right corner.
  • Select Web Service.
  • If prompted, click GitHub to connect your Git provider and authorize Render to access your repos.
  • Find your expense tracker repo in the list and click Connect.

The service creation form appears. Fill in these settings:

  • Set Name to something memorable (for example expense-tracker-api). This becomes part of your public URL.
  • Set Root Directory to server.
  • Set Language to Node.
  • Set Build Command to npm install.
  • Set Start Command to npm start.
  • Choose the Free instance type.

Now add your environment variables. Expand the Advanced section:

  • Click Add Environment Variable and add the first key MONGO_URI with the value of your Atlas connection string (the same one in your local .env file).
  • Add another variable: key JWT_SECRET with value myexpensetracker2026secretkey.
  • Add a third: key NODE_ENV with value production.
  • Click Create Web Service.

Render kicks off the first build. Watch the deploy logs on the Events page until you see a success message. This takes 1-2 minutes.

Once deployed, Render displays your service URL at the top of the page (for example https://expense-tracker-api.onrender.com). Copy this URL. You will need it for the frontend.

Before you move on, what do you think you will see if you visit your backend URL in the browser? Will it show your app, an error, or something else?

  • Open your Render backend URL in a new browser tab (for example https://expense-tracker-api.onrender.com).

You should see a blank page or a Cannot GET / message. That is expected because your Express server has no route defined for /. The API endpoints live at /api/auth and /api/expenses.

Deploy failed on Render?

Check the deploy logs for error details. Common causes: the Root Directory is not set to server, the start script is missing from package.json, or an environment variable is misspelled.

help me fix my Render backend deploy

Deploy the frontend on Render

Your React frontend needs to know the production backend URL so it can send API requests to the right place. Vite reads production environment variables from a .env.production file at build time.

  • In your client folder, create a new file called .env.production with this content:
VITE_API_URL=https://[[RENDER_BACKEND="your-backend-name"]].onrender.com

What does this file do?

When Vite builds your app for production it reads .env.production and bakes VITE_API_URL into the compiled JavaScript. Your Home.jsx and auth pages already reference import.meta.env.VITE_API_URL, so they will point to your live backend automatically.

  • Test the build locally to make sure it succeeds by running:
cd client
npm run build

You should see output ending with a message that the files were written to dist.

Build failed locally?

Check for any import errors or typos in your component files. The terminal output will show the exact file and line number where the error occurs.

help me fix my Vite build error

Now commit the new file and push to GitHub so Render can use it.

  • Push the update by running:
cd ..
git add .
git commit -m "Add .env.production for Render frontend"
git push

Now create the Static Site on Render.

  • Return to the Render Dashboard.
  • Click + New in the top-right corner.
  • Select Static Site.
  • Select the same GitHub repo you used for the backend and click Connect.

Fill in these settings:

  • Set Name to something like expense-tracker-frontend.
  • Set Root Directory to client.
  • Set Build Command to npm install && npm run build.
  • Set Publish Directory to dist.
  • Click Create Static Site.

Render builds and deploys your frontend. Wait for the deploy logs to show success (1-2 minutes).

Why set root directory to client?

Your GitHub repo contains both server and client folders. Setting the root directory to client tells Render to run the build command from inside that folder, where package.json and your React source live.

Since this app uses React Router for client-side routing, you need to add a rewrite rule so that refreshing on any page loads index.html instead of returning a 404.

  • In your Static Site's settings on Render, navigate to the Redirects/Rewrites tab.
  • Add a rewrite rule with Source /* and Destination /index.html, action Rewrite.
  • Click Save Changes.

Now open your frontend URL. What do you expect to see at the public URL?

  • Click the .onrender.com URL shown at the top of your Static Site page in the Render Dashboard.

You should see the Login page of your expense tracker, live on the internet.

What about cold starts?

Render free web services spin down after 15 minutes of inactivity. The first request after idle takes about 1 minute while the server spins back up. This only affects the backend. The frontend static site loads instantly because it is served from a CDN.

Seeing a blank page or network errors?

Open your browser's developer console (F12) and check the Network tab for failed requests. The most common issue is that VITE_API_URL in .env.production does not exactly match your backend Render URL. Fix the URL, commit, push, and Render will auto-redeploy.

Also confirm that your Atlas IP Access List includes 0.0.0.0/0. Without it the backend cannot connect to MongoDB.

help me fix my deployed expense tracker

Your expense tracker is now live on the internet. Next up, take on the Secret Mission and add the ability to edit expenses inline.

Secret mission

Add Expense Editing

Your expense tracker lets users add and delete expenses. But what if someone enters a typo or the wrong amount? In this secret mission, add inline editing so users can fix their expenses without deleting and re-creating them.

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 uses only free tier services, so there are no ongoing costs.

Resources you used:

  • Render Web Service (backend): Free tier, 750 instance hours per month. Spins down after 15 minutes of inactivity with about a 1 minute cold start on next request. No charges.
  • Render Static Site (frontend): Free to deploy, no spin-down. No charges.
  • MongoDB Atlas M0 cluster: Free tier, 512MB storage. Pauses automatically after 30 days of zero connections. No charges.
  • GitHub repository: Free. Stores your project source code.
  • Local project files: Your server/ and client/ folders on your computer.

Keep everything running

No action needed. Choose this if you want your expense tracker to stay live at its public URL.

  • Your Render free tier has no expiration. The app stays live indefinitely.
  • The backend will spin down after 15 minutes of inactivity. The first request after idle takes about 1 minute to wake up.
  • Your MongoDB Atlas cluster stays active as long as it receives at least one connection within every 30 days.
  • Your local project files remain on your computer for future editing.

Pause - I'll come back to this later

The Render free tier auto-sleeps after 15 minutes of inactivity, so there is no manual pause action required. Your services effectively pause themselves.

  • Your Render Web Service automatically sleeps when idle. It wakes up on the next incoming request.
  • Your Render Static Site stays available with no spin-down behavior.
  • Your MongoDB Atlas M0 cluster pauses itself after 30 days of zero connections. It resumes automatically when a new connection is made.
  • No action needed. Everything pauses on its own.

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 the Render Web Service: Open the Render Dashboard, click on your backend service, go to Settings, scroll to the bottom, and click Delete Web Service.
  • Delete the Render Static Site: In the Render Dashboard, click on your frontend static site, go to Settings, scroll to the bottom, and click Delete Static Site.
  • Delete or pause your MongoDB Atlas cluster: In the Atlas console, select your project, click the ... menu next to your cluster, and select Terminate. If you want to keep the cluster for other projects, you can leave it running at no cost.
  • Delete the GitHub repository: On GitHub, open your repository, click Settings, scroll to the Danger Zone section, and click Delete this repository.
  • Delete local project files by running this command in your terminal:
rm -rf ~/Desktop/server ~/Desktop/client

Adjust the paths if needed

If your project folders are not on your Desktop, replace the paths above with the actual location of your server/ and client/ folders. On Windows, use rmdir /s /q instead of rm -rf.

That's a Wrap!

That's a Wrap!

Nice work! You just took a local expense tracker and turned it into a secure, multi-user app running live on the internet.

You've learned how to:

  • Built a complete authentication system with bcryptjs password hashing and JWT tokens for stateless login.
  • Created auth middleware that protects API routes and scopes every expense query to the authenticated user.
  • Deployed the full-stack app to Render with environment variables and CORS configured, giving you a shareable public URL.
  • Secret Mission: Added inline expense editing with a protected PUT route and controlled form state in React.

Ready to quiz yourself?