REST API with Authentication in Node.js

Build a Task Manager REST API with JWT auth, validation, and protected routes.

Introduction

30 Second Summary

Every time you log in to an app and access your personal data, there's an API behind the scenes checking who you are and what you're allowed to do.

In this project, you will build a Task Manager REST API with user authentication using Node.js and Express. You'll implement registration, login, and protected routes that only verified users can access.

What You'll Build

A Task Manager API where users register, log in with JWT tokens, and manage personal tasks through protected CRUD endpoints.

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

  • A fully functional Express 5 REST API running locally with user registration and login endpoints that issue JWT tokens.
  • Protected CRUD routes for tasks that only authenticated users can access.
  • Input validation with Zod and a centralized error handling middleware for production-style structure.
  • Secret Mission: Add role-based access control with an admin role that can view all users' tasks.

Are there any prerequisites?

You should be comfortable with JavaScript fundamentals: functions, objects, async/await, and ES module imports. No prior backend experience is required.

Set Up Your Project

Before you can build the Task Manager API, you need the right tools and packages in place.

In this step, you'll create a Node.js project and install the four packages that power your API: Express for routing, jsonwebtoken for authentication, bcryptjs for password hashing, and Zod for input validation.

In this step, get ready to:
  • Create and initialize a Node.js project with ES modules.
  • Install all project dependencies.
  • Set up Thunder Client for API testing.
Create your project directory

Every Node.js project lives in its own folder with a package.json file that tracks its name, version, and dependencies.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api
  • Initialize the project with npm by running:
npm init -y

What does this command do?

The -y flag tells npm to accept all default values and generate a package.json file immediately.

This file is the manifest for your project. It tracks your project's name, version, and every package it depends on.

  • Confirm the file was created by running:
ls

You should see package.json listed in the output.

  • Open the task-manager-api folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-manager-api folder, and click Open.

VS Code asking if you trust the authors?

Click Yes, I trust the authors. This prompt appears whenever you open a new folder in VS Code.

Why is VS Code asking me to trust the authors?

Now you need to tell Node.js to use ES module syntax (import/export) instead of the older CommonJS syntax (require).

  • In the VS Code sidebar, click on package.json to open it.
  • Replace the entire contents of the file with the following:
{
  "name": "task-manager-api",
  "version": "1.0.0",
  "type": "module"
}

What does type: module do?

Setting "type": "module" tells Node.js that every .js file in this project uses the modern import/export syntax.

Without this, Node.js defaults to the older require() syntax. All the packages you install in this project expect ES module imports.

  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Install your dependencies

Your project needs four production packages and one development tool. Production packages run in the final app. Development packages only help during coding.

  • Open the terminal in VS Code by pressing Ctrl+` (both macOS and Windows).
  • Install the four production dependencies by running:
npm install --save-exact express@5.1.0 jsonwebtoken@9.0.3 bcryptjs@3.0.3 zod@3.25.42

What are these packages?

  • express is a web framework that handles HTTP requests and routing.
  • jsonwebtoken creates and verifies JWT tokens for authentication.
  • bcryptjs securely hashes passwords so they're never stored as plain text.
  • zod validates incoming data against schemas you define.
  • Install the development dependency by running:
npm install --save-exact --save-dev nodemon@3.1.9

What is nodemon?

Nodemon watches your files and automatically restarts the server whenever you save a change. It's only needed during development, not in production.

The --save-dev flag puts it in devDependencies instead of dependencies. The --save-exact flag pins the exact version without a ^ prefix, ensuring everyone on a team uses the same version.

You should see a new node_modules folder appear in the VS Code sidebar. This is where npm stores the downloaded packages.

Seeing permission errors or EACCES?

Permission errors during npm install usually mean npm is trying to write to a system directory. Make sure your terminal is inside the task-manager-api folder (not the Desktop or home directory).

Run pwd to check your current location. It should end with /task-manager-api.

Help me fix this permission error

Install Thunder Client and add your scripts

Thunder Client is a VS Code extension that lets you send HTTP requests to your API without leaving the editor. You'll use it to test every endpoint you build.

  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Thunder Client in the search bar at the top of the panel.
  • Click Install on the Thunder Client extension by Ranga Vadhineni.

You should see a thunder bolt icon appear in the left sidebar. That's your API testing tool ready to go.

Now add two scripts to your package.json so you can start your server with a single command.

  • Click on package.json in the VS Code sidebar to open it.
  • Find the line that says "type": "module" and add a comma at the end of it.
  • Below that line, add the following scripts section:
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  },

What do these scripts do?

  • dev runs your server through nodemon, which auto-restarts on file changes. You'll use this during development.
  • start runs your server directly with Node.js. This is for production where auto-restart isn't needed.
  • Save package.json.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that the thunder bolt icon is visible in the sidebar.

ⓧ I'd like to double check the full code

Your package.json should look like this. The order of the dependencies keys may be alphabetical on your machine, which is perfectly fine.

{
  "name": "task-manager-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "5.1.0",
    "jsonwebtoken": "9.0.3",
    "bcryptjs": "3.0.3",
    "zod": "3.25.42"
  },
  "devDependencies": {
    "nodemon": "3.1.9"
  }
}

JSON syntax error when saving?

JSON requires commas between every key-value pair except the last one in each object. Check that you added a comma after "type": "module" and that there's no trailing comma after the last item in devDependencies.

Help me find the JSON syntax error

Your environment is locked in. Next up, you'll create the Express server and build out the project structure.

Create the Express Server and Project Structure

Your project has all its packages installed and ready to go. Now it's time to write the actual server code that handles incoming requests.

Every Express app needs an entry point file that creates the application, configures middleware, and starts listening on a port. You'll also create a clean folder structure that keeps your routes, middleware, and data organized as the project grows.

In this step, get ready to:
  • Create the project folder structure with separate directories for routes, middleware, and data.
  • Build the Express server entry point with a health check route.
  • Test the running server with Thunder Client.
Create the folder structure and entry point

A well-organized project separates concerns into folders. Your API will have routes (endpoint handlers), middleware (request processors), and data (storage logic) in their own directories.

  • Create the folder structure by running this command in your VS Code terminal:
mkdir -p src/routes src/middleware src/data
  • Confirm the folders exist by running:
ls src/

You should see routes, middleware, and data listed in the output.

Don't see the folders?

Make sure you're running the command from your task-manager-api project folder. Run pwd to check your current directory.

Windows PowerShell users: if -p is not recognized, try running the commands separately: mkdir src/routes, then mkdir src/middleware, then mkdir src/data.

help me create the src folder structure on my operating system

Now you need the entry point file where your Express server lives.

  • In VS Code's Explorer sidebar, right-click the src folder.
  • Click New File.
  • Name it index.js and press Enter.
  • Paste the following code into src/index.js:
import express from 'express';

// Create the Express app and set the port
const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Health check route to verify the server is running
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • import express from 'express' loads the Express framework using ES module syntax (enabled by "type": "module" in your package.json).
  • express.json() is middleware that automatically parses JSON request bodies so you can access them via req.body.
  • The /health route is a simple endpoint that returns a JSON object confirming the server is alive. This is a common pattern used by monitoring tools to check service status.
  • app.listen() starts the server on port 3000 and logs a message when it's ready.
  • Save src/index.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red squiggly lines in your code?

If VS Code shows import errors, make sure your package.json has "type": "module" set. Without it, Node.js treats files as CommonJS and doesn't recognize the import keyword.

help me fix import errors in my Express project

Run the server and test the health endpoint

With the entry point written, you can start your server and confirm it responds to requests. The dev script uses nodemon to auto-restart the server whenever you save changes.

  • Start the development server by running:
npm run dev

You should see Server running on http://localhost:3000 printed in your terminal.

Seeing an error instead?

If you see a "Cannot find module" error, double-check that your file is at src/index.js (not just index.js in the project root). The dev script expects the file at src/index.js exactly.

If you see a port conflict error, another process may be using port 3000. Close other running servers or restart your terminal.

help me fix my Express server startup error

Now test the endpoint using Thunder Client to confirm your server responds correctly.

  • Click the thunderbolt icon in VS Code's left sidebar to open Thunder Client.
  • Click New Request.
  • Make sure the method is set to GET.
  • Enter this URL in the address bar:
http://localhost:3000/health
  • Click Send.

You should see a 200 OK status and a JSON response body containing status and timestamp fields.

Why a health check route?

A /health endpoint is a production best practice. Monitoring tools and load balancers ping this route to confirm your server is alive and responsive.

For development, it also gives you a quick way to verify everything is wired up correctly before building the real endpoints.

Getting "Could not get any response"?

Make sure your server is still running in the terminal. If you closed the terminal or stopped the process, run npm run dev again.

Check that the URL is exactly http://localhost:3000/health with no trailing slash or typos.

help me connect Thunder Client to my Express server

✔️ Awesome, I've got everything!

Great. Double check you've saved src/index.js and your server is still running in the terminal.

ⓧ I'd like to double check the full code

Here's what src/index.js should look like:

import express from 'express';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your server is live and responding to requests. Next up, you'll build the registration and login endpoints that let users create accounts and get authentication tokens.

Build User Registration and Login

Your server is running and responding to requests. Now you need to give users a way to identify themselves.

Authentication is the backbone of any multi-user API. Without it, anyone could access anyone else's data. In this step, you'll build a registration and login system that issues JWT tokens. These tokens let your API verify who is making each request without asking for credentials every time.

In this step, get ready to:
  • Create a user data store and registration endpoint that hashes passwords and returns a token.
  • Build a login endpoint that verifies credentials and issues a token.
  • Mount the auth router and test both endpoints with Thunder Client.
Create the user data store and registration endpoint

Before you can register users, you need somewhere to store them. You'll use a simple in-memory array with helper functions. In a production app this would be a database, but the patterns remain identical.

  • Create a new file called src/data/users.js by running this command in your terminal:
touch src/data/users.js
  • Open src/data/users.js in VS Code and add the following code:
const users = [];

export function findUserByEmail(email) {
  return users.find(user => user.email === email);
}

export function findUserById(id) {
  return users.find(user => user.id === id);
}

export function addUser(user) {
  users.push(user);
  return user;
}

What does this code do?

This module acts as your user database. The users array holds all registered users in memory.

  • findUserByEmail() searches for a user by their email address. You'll use this during registration (to prevent duplicates) and login (to find the account).
  • findUserById() looks up a user by their unique ID. You'll use this later when verifying tokens.
  • addUser() pushes a new user object into the array and returns it.
  • Save src/data/users.js.

Now you'll create the auth router with the registration endpoint. This is where the real work happens: validating input, hashing passwords, and generating tokens.

  • Create a new file called src/routes/auth.js by running:
touch src/routes/auth.js
  • Open src/routes/auth.js and add the file setup with imports, router creation, and validation schemas:
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { findUserByEmail, addUser } from '../data/users.js';

export const authRouter = Router();

// Secret key for signing JWT tokens
const JWT_SECRET = 'your-secret-key-here';

// Validation schema for registration
const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6, 'Password must be at least 6 characters')
});

// Validation schema for login
const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1, 'Password is required')
});

What does this setup do?

You're importing four libraries that each handle a different concern. Router from Express creates a modular set of routes. jsonwebtoken signs and verifies tokens. bcryptjs hashes passwords. Zod validates incoming request data.

The two schemas define what valid registration and login requests look like. If a request body doesn't match the schema, Zod rejects it before your handler logic even runs. This keeps your route handlers clean and focused on business logic.

Now add the registration endpoint below the schemas in the same file.

  • Add the POST /register route handler below the loginSchema definition:
authRouter.post('/register', async (req, res, next) => {
  // Validate the request body against the schema
  const result = registerSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const { email, password } = result.data;

  // Check if a user with this email already exists
  if (findUserByEmail(email)) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  // Hash the password with a salt factor of 10
  const hashedPassword = await bcrypt.hash(password, 10);
  const user = {
    id: crypto.randomUUID(),
    email,
    password: hashedPassword
  };

  // Store the user and generate a JWT token
  addUser(user);

  const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '24h' });
  res.status(201).json({ token, user: { id: user.id, email: user.email } });
});

What does the register handler do?

This handler follows a pattern you'll see in every production auth system. First, it validates the input with safeParse(). If validation fails, it returns a 400 error with details about what went wrong.

Then it checks for duplicate emails (409 Conflict). Next, bcrypt.hash() converts the plain-text password into a one-way hash. The number 10 is the salt rounds, which controls how computationally expensive the hash is. Finally, jwt.sign() creates a token containing the user's ID and email that expires in 24 hours.

Why hash passwords?

If you stored passwords as plain text, anyone who gains access to your data (a breach, a careless log, a debug dump) would see every user's real password. Hashing is a one-way transformation. You can verify a password against a hash, but you cannot reverse-engineer the original password from the hash.

  • Save src/routes/auth.js.

Seeing a syntax error in your terminal?

Since nodemon is watching your files, it may try to restart the server before you finish editing. If you see errors about missing exports or unexpected tokens, make sure you saved both src/data/users.js and src/routes/auth.js completely.

help me fix a syntax error in my auth route

Add the login endpoint

Registration creates new accounts. Login lets existing users prove their identity and get a fresh token.

  • In src/routes/auth.js, add the POST /login route handler below the register handler:
authRouter.post('/login', async (req, res, next) => {
  // Validate the request body
  const result = loginSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const { email, password } = result.data;
  const user = findUserByEmail(email);

  // Return a generic error if user not found (don't reveal which field is wrong)
  if (!user) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  // Compare the provided password with the stored hash
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  // Generate and return a new token
  const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '24h' });
  res.json({ token, user: { id: user.id, email: user.email } });
});

What does the login handler do?

The login handler validates input, finds the user by email, then uses bcrypt.compare() to check if the submitted password matches the stored hash. Notice both the "user not found" and "wrong password" cases return the same generic error message. This is intentional. If you returned different messages, an attacker could use the difference to figure out which emails exist in your system.

  • Save src/routes/auth.js.

Login always returning 401?

Make sure you're logging in with the same email and password you used to register. Remember that the data store is in-memory, so if the server restarted (nodemon restarts on file save), all registered users are gone. Register again before trying to log in.

help me debug why my login endpoint always returns 401

Mount the auth router and test with Thunder Client

Your auth routes are built but not connected to the main app yet. You need to mount the router so Express knows to direct requests to it.

  • In src/index.js, find this line at the top of the file:
import express from 'express';
  • Add this import directly below it:
import { authRouter } from './routes/auth.js';
  • Now find the /health route in the same file:
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
  • Add this line directly below the health route's closing }); to mount the auth router:
app.use('/api/auth', authRouter);

What does mounting a router do?

When you call app.use('/api/auth', authRouter), Express prepends /api/auth to every route defined in authRouter. So authRouter.post('/register') becomes POST /api/auth/register on your server. This keeps your routes organized by feature.

  • Save src/index.js. Your terminal should show nodemon restarting the server automatically.

Now test both endpoints using Thunder Client to confirm everything works.

  • Click the Thunder Client icon in the VS Code Activity Bar (the lightning bolt on the left sidebar).
  • Click New Request.
  • Change the method dropdown from GET to POST.
  • Enter http://localhost:3000/api/auth/register in the URL field.
  • Click the Body tab below the URL field.
  • Select JSON as the content type.
  • Paste the following JSON into the body editor:
{
  "email": "test@example.com",
  "password": "secret123"
}
  • Click Send.

You should see a 201 response with a JSON body containing a token string and a user object with id and email fields.

Now test the login endpoint.

  • Click New Request again.
  • Set the method to POST.
  • Enter http://localhost:3000/api/auth/login in the URL field.
  • In the Body tab, select JSON and paste:
{
  "email": "test@example.com",
  "password": "secret123"
}
  • Click Send.

You should see a 200 response with a token and the same user object. This confirms your login endpoint finds the registered user and verifies the password hash correctly.

Getting a 404 or connection error?

Check your terminal to make sure the server is still running. Look for the Server running on http://localhost:3000 message. If the server crashed, look at the error message above it for clues about what went wrong.

Also confirm the URL is exactly http://localhost:3000/api/auth/register (not https, and not missing /api).

help me troubleshoot my Express auth endpoints not responding

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

const users = [];

export function findUserByEmail(email) {
  return users.find(user => user.email === email);
}

export function findUserById(id) {
  return users.find(user => user.id === id);
}

export function addUser(user) {
  users.push(user);
  return user;
}
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { findUserByEmail, addUser } from '../data/users.js';

export const authRouter = Router();

const JWT_SECRET = 'your-secret-key-here';

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6, 'Password must be at least 6 characters')
});

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1, 'Password is required')
});

authRouter.post('/register', async (req, res, next) => {
  const result = registerSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const { email, password } = result.data;

  if (findUserByEmail(email)) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  const hashedPassword = await bcrypt.hash(password, 10);
  const user = {
    id: crypto.randomUUID(),
    email,
    password: hashedPassword
  };

  addUser(user);

  const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '24h' });
  res.status(201).json({ token, user: { id: user.id, email: user.email } });
});

authRouter.post('/login', async (req, res, next) => {
  const result = loginSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const { email, password } = result.data;
  const user = findUserByEmail(email);

  if (!user) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, { expiresIn: '24h' });
  res.json({ token, user: { id: user.id, email: user.email } });
});
import express from 'express';
import { authRouter } from './routes/auth.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use('/api/auth', authRouter);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your API now has a working auth system. Users can register, get a token, and log in to get a new token. Next up, you'll use that token to protect routes so only authenticated users can access their tasks.

Add Authentication Middleware and Protected Routes

Your users can register and log in, but those tokens don't actually protect anything yet. Right now, anyone could hit your endpoints without proving who they are.

In this step, you'll build the gate that checks every request for a valid JWT token. Then you'll create the task CRUD routes that only authenticated users can access.

In this step, get ready to:
  • Create authentication middleware that verifies JWT tokens.
  • Build protected CRUD routes for tasks.
  • Test the full authentication flow with Thunder Client.
Create the authentication middleware

Middleware in Express is a function that runs before your route handler. It can inspect the request, reject it, or enrich it with data for the next function in the chain.

  • Create a new file called src/middleware/auth.js and add the following code:
import jwt from 'jsonwebtoken';

const JWT_SECRET = 'your-secret-key-here';

// Middleware that checks for a valid JWT in the Authorization header
export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  // Reject if no header or wrong format
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  // Extract the token after "Bearer "
  const token = authHeader.split(' ')[1];

  try {
    // Verify token and attach decoded payload to the request
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

What does this code do?

This middleware intercepts every request before it reaches your route handler. It looks for an Authorization header in the format Bearer <token>.

If the token is valid, jwt.verify() decodes the payload (the user's ID and email you signed in earlier) and attaches it to req.user. Calling next() passes control to the route handler.

  • Save src/middleware/auth.js.

Why the same JWT_SECRET in two files?

The secret used to sign tokens in auth.js must match the secret used to verify them here. In a production app, you'd store this in an environment variable so both files read from the same source. For this project, the hardcoded string keeps things simple.

Build the task data store and CRUD routes

Tasks need a place to live and routes to manage them. You'll create an in-memory data store (just like you did for users) and then a route file with full CRUD operations.

  • Create a new file called src/data/tasks.js and add the following code:
const tasks = [];
let nextId = 1;

// Get all tasks belonging to a specific user
export function getTasksByUserId(userId) {
  return tasks.filter(task => task.userId === userId);
}

// Find a single task by ID, scoped to the user
export function getTaskById(id, userId) {
  return tasks.find(task => task.id === id && task.userId === userId);
}

// Create a new task and assign it to a user
export function addTask(task, userId) {
  const newTask = {
    id: nextId++,
    title: task.title,
    completed: false,
    userId,
    createdAt: new Date().toISOString()
  };
  tasks.push(newTask);
  return newTask;
}

// Update a task's title or completed status
export function updateTask(id, data, userId) {
  const task = tasks.find(t => t.id === id && t.userId === userId);
  if (!task) return null;
  if (data.title !== undefined) task.title = data.title;
  if (data.completed !== undefined) task.completed = data.completed;
  return task;
}

// Remove a task from the array
export function deleteTask(id, userId) {
  const index = tasks.findIndex(t => t.id === id && t.userId === userId);
  if (index === -1) return false;
  tasks.splice(index, 1);
  return true;
}

What does this code do?

Every function takes a userId parameter. This ensures users can only access their own tasks. The nextId counter auto-increments so each task gets a unique numeric ID.

  • Save src/data/tasks.js.

Now create the route file that uses both the auth middleware and the task data store.

  • Create a new file called src/routes/tasks.js and add the router setup with validation schemas:
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.js';
import { getTasksByUserId, getTaskById, addTask, updateTask, deleteTask } from '../data/tasks.js';

export const tasksRouter = Router();

// Apply auth middleware to ALL routes in this file
tasksRouter.use(authenticate);

// Validation: title is required, 1-200 characters
const createTaskSchema = z.object({
  title: z.string().min(1).max(200)
});

// Validation: both fields optional for partial updates
const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  completed: z.boolean().optional()
});

What does this code do?

The key line is tasksRouter.use(authenticate). This applies your auth middleware to every route registered on this router. Any request without a valid token gets rejected before reaching the route handlers below.

The two Zod schemas define what valid input looks like for creating and updating tasks.

  • Below the schemas in the same file, add the GET and POST route handlers:
// GET /api/tasks - retrieve all tasks for the logged-in user
tasksRouter.get('/', (req, res) => {
  const tasks = getTasksByUserId(req.user.id);
  res.json({ tasks });
});

// POST /api/tasks - create a new task
tasksRouter.post('/', (req, res) => {
  const result = createTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = addTask(result.data, req.user.id);
  res.status(201).json({ task });
});

What does this code do?

The GET handler uses req.user.id (set by the auth middleware) to fetch only the current user's tasks. The POST handler validates the request body with Zod, then passes the validated data to addTask().

  • Below the POST handler, add the PATCH and DELETE route handlers:
// PATCH /api/tasks/:id - update a task's title or completion status
tasksRouter.patch('/:id', (req, res) => {
  const result = updateTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = updateTask(Number(req.params.id), result.data, req.user.id);
  if (!task) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.json({ task });
});

// DELETE /api/tasks/:id - remove a task
tasksRouter.delete('/:id', (req, res) => {
  const deleted = deleteTask(Number(req.params.id), req.user.id);
  if (!deleted) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.status(204).send();
});

What does this code do?

PATCH uses Number(req.params.id) to convert the URL parameter from a string to a number (matching the numeric IDs in your data store). Both handlers return 404 if the task doesn't exist or doesn't belong to the current user.

DELETE returns status 204 (No Content) on success. This is the standard HTTP response for a successful deletion with no response body.

  • Save src/routes/tasks.js.

Seeing a red squiggly on an import?

If VS Code highlights ../middleware/auth.js or ../data/tasks.js as an error, make sure you saved both of those files first. The import paths must match the filenames and locations exactly.

Help me fix an import error in my tasks route file.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.js';
import { getTasksByUserId, getTaskById, addTask, updateTask, deleteTask } from '../data/tasks.js';

export const tasksRouter = Router();

tasksRouter.use(authenticate);

const createTaskSchema = z.object({
  title: z.string().min(1).max(200)
});

const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  completed: z.boolean().optional()
});

tasksRouter.get('/', (req, res) => {
  const tasks = getTasksByUserId(req.user.id);
  res.json({ tasks });
});

tasksRouter.post('/', (req, res) => {
  const result = createTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = addTask(result.data, req.user.id);
  res.status(201).json({ task });
});

tasksRouter.patch('/:id', (req, res) => {
  const result = updateTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = updateTask(Number(req.params.id), result.data, req.user.id);
  if (!task) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.json({ task });
});

tasksRouter.delete('/:id', (req, res) => {
  const deleted = deleteTask(Number(req.params.id), req.user.id);
  if (!deleted) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.status(204).send();
});
Mount the tasks router and test the full flow

Your task routes exist but Express doesn't know about them yet. You need to import and mount the router in your main entry file.

  • In src/index.js, find this line near the top:
import { authRouter } from './routes/auth.js';
  • Add this import directly below it:
import { tasksRouter } from './routes/tasks.js';
  • Now find this line further down:
app.use('/api/auth', authRouter);
  • Add this line directly below it:
app.use('/api/tasks', tasksRouter);
  • Save src/index.js. Nodemon will restart your server automatically.

Server crashed after saving?

Check your terminal for the error message. The most common cause is a typo in the import path. Make sure the path is ./routes/tasks.js (with the ./ prefix and .js extension).

Help me fix my server crash after mounting the tasks router.

Now test the full authentication flow in Thunder Client. You'll register a user, grab the token, and use it to create and retrieve tasks.

  • Open Thunder Client from the VS Code sidebar (lightning bolt icon).
  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/auth/register
  • Click the Body tab, select JSON, and enter:
{
  "email": "test@example.com",
  "password": "password123"
}
  • Click Send. You should get a 201 response with a token field.
  • Copy the token value from the response (everything inside the quotes after "token":).
  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/tasks
  • Click the Auth tab in Thunder Client.
  • Select Bearer from the auth type dropdown.
  • Paste your token into the token field.
  • Click the Body tab, select JSON, and enter:
{
  "title": "Learn about JWT authentication"
}
  • Click Send. You should get a 201 response with your new task object including an id, title, completed: false, and a createdAt timestamp.
  • Change the method to GET (keep the same URL and Bearer token) and click Send again.
  • You should see a tasks array containing the task you just created.

What just happened?

You just completed a full authentication round trip. The register endpoint issued a token, and the task endpoints verified that token before allowing access. Without the token, the middleware returns 401 and the route handler never runs.

Getting 401 Authentication required?

Make sure you selected Bearer in the Auth tab (not Basic or other types). The token must be pasted without any surrounding quotes or extra spaces.

If you restarted the server since registering, your in-memory data was wiped. Register a fresh user to get a new token.

Help me debug why my task request returns 401.

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on.

ⓧ I'd like to double check the full code

import jwt from 'jsonwebtoken';

const JWT_SECRET = 'your-secret-key-here';

export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
const tasks = [];
let nextId = 1;

export function getTasksByUserId(userId) {
  return tasks.filter(task => task.userId === userId);
}

export function getTaskById(id, userId) {
  return tasks.find(task => task.id === id && task.userId === userId);
}

export function addTask(task, userId) {
  const newTask = {
    id: nextId++,
    title: task.title,
    completed: false,
    userId,
    createdAt: new Date().toISOString()
  };
  tasks.push(newTask);
  return newTask;
}

export function updateTask(id, data, userId) {
  const task = tasks.find(t => t.id === id && t.userId === userId);
  if (!task) return null;
  if (data.title !== undefined) task.title = data.title;
  if (data.completed !== undefined) task.completed = data.completed;
  return task;
}

export function deleteTask(id, userId) {
  const index = tasks.findIndex(t => t.id === id && t.userId === userId);
  if (index === -1) return false;
  tasks.splice(index, 1);
  return true;
}
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth.js';
import { getTasksByUserId, getTaskById, addTask, updateTask, deleteTask } from '../data/tasks.js';

export const tasksRouter = Router();

tasksRouter.use(authenticate);

const createTaskSchema = z.object({
  title: z.string().min(1).max(200)
});

const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  completed: z.boolean().optional()
});

tasksRouter.get('/', (req, res) => {
  const tasks = getTasksByUserId(req.user.id);
  res.json({ tasks });
});

tasksRouter.post('/', (req, res) => {
  const result = createTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = addTask(result.data, req.user.id);
  res.status(201).json({ task });
});

tasksRouter.patch('/:id', (req, res) => {
  const result = updateTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Validation failed', details: result.error.issues });
  }

  const task = updateTask(Number(req.params.id), result.data, req.user.id);
  if (!task) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.json({ task });
});

tasksRouter.delete('/:id', (req, res) => {
  const deleted = deleteTask(Number(req.params.id), req.user.id);
  if (!deleted) {
    return res.status(404).json({ error: 'Task not found' });
  }

  res.status(204).send();
});
import express from 'express';
import { authRouter } from './routes/auth.js';
import { tasksRouter } from './routes/tasks.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use('/api/auth', authRouter);
app.use('/api/tasks', tasksRouter);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Your API now has real authentication protecting real data. Next up, you'll add input validation and centralized error handling to make your API production-ready.

Add Input Validation and Error Handling

Your API handles happy-path requests, but what happens when a user sends garbage data or hits a URL that does not exist? Right now, each route handles its own validation errors inline.

A production API needs a single, centralized place to catch and format all errors consistently. In this step, you will review the validation already in place, build a centralized error handler, and stress-test your API with bad requests.

In this step, get ready to:
  • Review the Zod validation schemas on your task routes.
  • Create a centralized error handling middleware.
  • Test error cases to confirm correct status codes and messages.
Review your existing input validation

In the previous step, you already added Zod validation schemas to your task routes. These schemas run on every incoming request before your business logic executes.

  • In VS Code, open src/routes/tasks.js and find the two schemas near the top of the file:
const createTaskSchema = z.object({
  title: z.string().min(1).max(200)
});

const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  completed: z.boolean().optional()
});

What do these schemas do?

createTaskSchema ensures every new task has a title that is a string between 1 and 200 characters. Without it, a user could create a task with an empty string or a 10,000-character title.

updateTaskSchema allows partial updates. You can send just a new title, just completed, or both. Both fields are optional, but if present they must match their type constraints.

Each route uses .safeParse(req.body) to validate input before processing. If validation fails, the route returns a 400 response with the error details.

Why safeParse instead of parse?

Calling .parse() throws a ZodError on invalid input. Calling .safeParse() returns a result object instead, giving you full control over how to respond. This avoids unhandled exceptions crashing your server.

Create the centralized error handler

Your routes already handle validation errors inline, but unhandled exceptions, malformed JSON, and unknown routes still need a safety net. A centralized error handler catches anything that slips through.

What is error handling middleware?

Express identifies error-handling middleware by its four-argument signature: (err, req, res, next). When any middleware or route calls next(err) or throws an unhandled error, Express skips all normal middleware and jumps straight to the error handler.

You must register it last, after all other app.use() calls and routes. This ensures it catches errors from every layer above it.

  • Create a new file src/middleware/errorHandler.js and paste the following code:
import { ZodError } from 'zod';

// Centralized error handler - catches all unhandled errors
export function errorHandler(err, req, res, next) {
  // Handle Zod validation errors with a 400 status
  if (err instanceof ZodError) {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.issues
    });
  }

  // Handle errors that already have a status code
  if (err.status) {
    return res.status(err.status).json({ error: err.message });
  }

  // Catch-all for unexpected errors
  console.error('Unexpected error:', err);
  res.status(500).json({ error: 'Internal server error' });
}

What does this code do?

  • err instanceof ZodError catches any validation error that was thrown (not caught inline) and returns a 400 with the specific issues array.
  • err.status handles errors where another middleware already set a status code (like 401 or 404).
  • The final res.status(500) is the safety net for truly unexpected errors. It logs the full error for debugging but only shows a generic message to the client.
  • Save src/middleware/errorHandler.js.

Seeing a syntax error in the terminal?

Make sure you imported ZodError with curly braces: import { ZodError } from 'zod'. A default import (without braces) will fail because Zod exports ZodError as a named export.

help me fix my ZodError import

Now you need to wire the error handler into your Express app. You will also add a 404 catch-all route for any URL that does not match a defined endpoint.

  • Open src/index.js and replace the entire file with this updated code:
import express from 'express';
import { authRouter } from './routes/auth.js';
import { tasksRouter } from './routes/tasks.js';
import { errorHandler } from './middleware/errorHandler.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use('/api/auth', authRouter);
app.use('/api/tasks', tasksRouter);

// Catch-all for undefined routes
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

// Centralized error handler - must be last
app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What changed in index.js?

  • The new import { errorHandler } line at the top brings in your centralized handler.
  • The 404 catch-all middleware runs after all route mounts. If no route matched, this sends a clean JSON error instead of Express's default HTML page.
  • app.use(errorHandler) is registered last so it catches errors from every layer above it.
  • Save src/index.js.
  • Check your terminal. Nodemon should restart automatically and show Server running on http://localhost:3000 with no errors.

Server not restarting?

If nodemon shows an error about the import path, double-check that the file is saved as src/middleware/errorHandler.js (not errorhandler.js or error-handler.js). File names are case-sensitive on Linux and macOS.

help me debug why my errorHandler import is failing

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full src/index.js file:

import express from 'express';
import { authRouter } from './routes/auth.js';
import { tasksRouter } from './routes/tasks.js';
import { errorHandler } from './middleware/errorHandler.js';

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use('/api/auth', authRouter);
app.use('/api/tasks', tasksRouter);

app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

app.use(errorHandler);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

And here is src/middleware/errorHandler.js:

import { ZodError } from 'zod';

export function errorHandler(err, req, res, next) {
  if (err instanceof ZodError) {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.issues
    });
  }

  if (err.status) {
    return res.status(err.status).json({ error: err.message });
  }

  console.error('Unexpected error:', err);
  res.status(500).json({ error: 'Internal server error' });
}
Test error cases with Thunder Client

Your error handler is wired up, but you need to prove it works. You will send deliberately bad requests and verify the API responds with the correct status codes.

First, register a fresh user so you have a valid token to work with. The in-memory store resets every time nodemon restarts the server.

  • In Thunder Client, create a new request.
  • Set the method to POST and the URL to http://localhost:3000/api/auth/register.
  • In the Body tab, select JSON and enter:
{
  "email": "test@example.com",
  "password": "password123"
}
  • Click Send.
  • Copy the token value from the response. You will use it in the next tests.

Now test three error scenarios:

Test 1: Missing authentication token (401).

  • Create a new request set to GET with URL http://localhost:3000/api/tasks.
  • Leave the Auth tab empty (no Bearer token).
  • Click Send.
  • You should see status 401 with the message Authentication required.

Test 2: Invalid input data (400).

  • Create a new request set to POST with URL http://localhost:3000/api/tasks.
  • In the Auth tab, select Bearer and paste your token.
  • In the Body tab, select JSON and enter an empty object:
{}
  • Click Send.
  • You should see status 400 with Validation failed and a details array describing the missing title field.

Test 3: Non-existent task ID (404).

  • Create a new request set to PATCH with URL http://localhost:3000/api/tasks/999.
  • In the Auth tab, select Bearer and paste your token.
  • In the Body tab, enter:
{
  "title": "Updated title"
}
  • Click Send.
  • You should see status 404 with Task not found.

Why test with bad data?

In production, attackers and buggy clients send unexpected data constantly. Testing error paths ensures your API fails gracefully instead of crashing or leaking internal details.

Getting a different status code?

If you see a 500 instead of a 400, check that your task route still uses .safeParse() and returns early on failure. If you accidentally switched to .parse(), the thrown error reaches the centralized handler instead of the inline response.

help me debug why my task validation returns the wrong status code

Your API now validates every input and responds with consistent, meaningful error messages. Next up, you will extend this foundation with role-based access control in the Secret Mission.

Secret mission

Add Role-Based Access Control

Your API authenticates users, but treats everyone the same. In production, some users need elevated permissions. Can you add an admin role that can view all users' tasks while regular users stay locked to their own?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-manager-api project folder on your Desktop.
  • The running nodemon server process (port 3000).
  • Thunder Client extension in VS Code.

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the API further.

  • Your server stays running on port 3000.
  • All project files remain in the task-manager-api folder on your Desktop.
  • Thunder Client stays installed for future API testing.

Pause - I'll come back to this later

Stop the running server to free up port 3000 and terminal resources. Your project files stay on disk so you can resume later.

  • Switch to the terminal where nodemon is running.
  • Press Ctrl+C to stop the server.
  • When you're ready to resume, open the task-manager-api folder in VS Code and run npm run dev again.

Delete - I don't want to use this again

Remove all project files and stop the server process completely.

  • Switch to the terminal where nodemon is running and press Ctrl+C to stop the server.
  • Delete the project folder by running the following commands.

macOS

cd ~/Desktop
rm -rf task-manager-api

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • To uninstall Thunder Client, open the Extensions panel in VS Code (Cmd+Shift+X on macOS or Ctrl+Shift+X on Windows).
  • Find Thunder Client in your installed extensions list, click the gear icon, and select Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-structured REST API with authentication from scratch using Node.js and Express.

You've learned how to:

  • Build a Node.js + Express 5 REST API with JWT-based authentication for user registration and login.
  • Create protected CRUD routes that only authenticated users can access, using middleware to verify tokens and attach user context.
  • Add input validation with Zod and centralized error handling to catch and format errors consistently across your API.
  • Added role-based access control with an admin role that can view all users' tasks.

Ready to quiz yourself?