Build a Task Management API

Build a REST API with JWT auth, Prisma ORM, and PostgreSQL on Neon.

Introduction

30 Second Summary

Every app you use daily relies on a backend API to store data, authenticate users, and enforce security rules. Building one from scratch is one of the strongest signals to employers that you understand how production software actually works.

In this project, you will build a Task Management API with user authentication, a PostgreSQL database, and a full security middleware stack that demonstrates real-world backend engineering patterns.

What You'll Build

A REST API built with Express.js, Prisma ORM, and PostgreSQL on Neon that handles user registration, login with tokens, and per-user task management with production security features.

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

  • A working REST API with JWT-based authentication including register, login, and token refresh endpoints.
  • Task CRUD endpoints with per-user data isolation so users can only access their own tasks.
  • A production security middleware stack including rate limiting, input validation with Zod, and security headers with Helmet.
  • Secret Mission: Add pagination, filtering, and sorting to make the tasks endpoint production-grade.

Are there any prerequisites?

You should have basic JavaScript knowledge and familiarity with HTTP methods (GET, POST, PUT, DELETE). No prior experience with Express.js, databases, or authentication is required.

Set Up the Project

Every production API needs a solid foundation before you write any business logic. That means a clear folder structure, pinned dependency versions, and a live database connection.

In this step, you will scaffold the project directory, install all the packages your API depends on, connect to a cloud PostgreSQL database on Neon, and verify that your ORM can reach the database.

In this step, get ready to:
  • Initialize the project and install all dependencies.
  • Create a Neon PostgreSQL database and configure environment variables.
  • Initialize Prisma and verify the database connection.
Initialize the project and install dependencies

Before you can install packages, you need Node.js available on your machine. Node.js provides the JavaScript runtime and the npm package manager you will use throughout this project.

  • Check that Node.js is installed by running this command in any terminal:
node --version

✔️ I see a version number

Node.js is ready. Move on to opening VS Code below.

ⓧ Command not found

Node.js is not installed yet.

  • Download and install Node.js from the official site: https://nodejs.org.
  • Choose the LTS version and follow the installer prompts for your operating system.
  • After installation, close and reopen your terminal, then run node --version again to confirm it works.
  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to launch it.
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on 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-api
cd task-api
  • Initialize the project with a default package.json by running:
npm init -y

What does this command do?

The -y flag accepts all defaults so npm creates package.json without asking questions. This file tracks your project's name, version, scripts, and dependencies.

  • Install all production dependencies by running:
npm install express@4.22.2 @prisma/client@7.8.0 jsonwebtoken@9.0.3 bcryptjs@3.0.3 helmet@8.2.0 cors@2.8.6 express-rate-limit@8.5.2 zod@3.24.4 dotenv@16.4.7

What are these packages?

  • express is the web framework that handles HTTP requests and routing.
  • @prisma/client is the auto-generated database client that gives you type-safe queries.
  • jsonwebtoken creates and verifies JWT tokens for authentication.
  • bcryptjs hashes passwords securely before storing them.
  • helmet, cors, and express-rate-limit add security headers, cross-origin support, and rate limiting.
  • zod validates incoming request data against schemas you define.
  • dotenv loads environment variables from a .env file into your app.
  • Install the dev dependencies by running:
npm install --save-dev prisma@7.8.0 nodemon@3.1.11

What are dev dependencies?

Dev dependencies are tools you only need during development, not in production. prisma is the CLI that generates migrations and the Prisma Client. nodemon watches your files and restarts the server automatically when you save changes.

Now you need to update package.json with the correct entry point, description, and scripts.

  • Open package.json in VS Code by clicking on it in the Explorer sidebar on the left.
  • Replace the entire contents of the file with the following:
{
  "name": "task-api",
  "version": "1.0.0",
  "description": "Production-ready Task Management API",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "@prisma/client": "7.8.0",
    "bcryptjs": "3.0.3",
    "cors": "2.8.6",
    "dotenv": "16.4.7",
    "express": "4.22.2",
    "express-rate-limit": "8.5.2",
    "helmet": "8.2.0",
    "jsonwebtoken": "9.0.3",
    "zod": "3.24.4"
  },
  "devDependencies": {
    "nodemon": "3.1.11",
    "prisma": "7.8.0"
  }
}

What did you change?

  • "main" points to src/server.js where your app will boot from.
  • "start" runs the server in production mode with plain Node.
  • "dev" runs the server with nodemon so it auto-restarts when you save files.
  • Unused fields like keywords and license are removed to keep the file clean.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
  • Run npm install once more to sync the lock file with your cleaned-up package.json:
npm install

Seeing npm warnings?

Warnings about missing description or repository fields are safe to ignore. As long as you do not see red text with ERR!, the install succeeded.

help me fix npm install errors in my task-api project

Next, create the folder structure that separates your code into layers.

  • Create the project folders by running this command:

macOS

mkdir -p src/routes src/controllers src/services src/middleware src/utils

Windows

mkdir src\routes, src\controllers, src\services, src\middleware, src\utils

Why this folder structure?

This is a layered architecture pattern used in production Node.js apps. Routes define URL paths. Controllers handle request/response logic. Services contain business logic.

Middleware processes requests before they reach controllers. Utils hold shared helpers like the database client.

  • Confirm the folders exist by running:
ls src

You should see controllers, middleware, routes, services, and utils listed in the output.

Set up Neon PostgreSQL

Your API needs a database to store users and tasks. Neon provides a free managed PostgreSQL database that requires zero local setup.

  • Open your browser and go to https://console.neon.tech.
  • Sign in with your Neon account.
  • On the Projects page, click New Project.
  • Give your project a name (for example, task-api) and pick the region closest to you.
  • Click Create Project.

After creation, Neon shows you the project dashboard with your connection details.

  • On your project dashboard, find the Connection string section.
  • Copy the full connection string. It starts with postgresql:// and includes your username, password, host, and database name.

What is a connection string?

A connection string is a single URL that contains everything your app needs to connect to the database: the protocol, username, password, host address, and database name. You will paste this into your .env file so your code can reach the database without hardcoding credentials.

Now create the environment file that stores your secrets locally.

  • In VS Code, create a new file called .env in the root of your task-api folder.
  • Paste the following content into .env, replacing the DATABASE_URL placeholder with the connection string you copied from Neon:
PORT=3000
DATABASE_URL=your-neon-connection-string-here
JWT_SECRET=your-super-secret-key-change-this-in-production-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this-in-production-min-32-chars

What do these variables do?

  • PORT tells Express which port to listen on.
  • DATABASE_URL is the Neon connection string Prisma uses to talk to PostgreSQL.
  • JWT_SECRET signs access tokens so your API can verify who made a request.
  • JWT_REFRESH_SECRET signs refresh tokens that let users get new access tokens without logging in again.

Keep your secrets out of Git

Never commit .env to version control. When you add a .gitignore file later, include .env in it to prevent accidental exposure of your database credentials and JWT secrets.

  • Save .env.
Initialize Prisma and connect to Neon

Prisma is an ORM that lets you define your database schema in a readable file and generates a type-safe client for queries. Initializing Prisma creates the schema file where you will define your User and Task models in the next step.

  • Initialize Prisma with PostgreSQL as the datasource by running:
npx prisma init --datasource-provider postgresql

What does this command do?

This creates a prisma/ directory with a schema.prisma file inside it. The --datasource-provider postgresql flag sets the database type to PostgreSQL and configures it to read the connection URL from the DATABASE_URL environment variable.

Seeing extra lines in your .env file?

Prisma may append a placeholder DATABASE_URL line to your existing .env file. If you see any lines below your JWT_REFRESH_SECRET entry, delete those extra lines. Your .env should only contain the four lines you pasted earlier.

help me fix Prisma init modifying my .env file

  • Open prisma/schema.prisma in VS Code and verify it looks like this:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

The datasource block tells Prisma to connect to PostgreSQL using the DATABASE_URL from your .env file.

Now verify that Prisma can actually reach your Neon database.

  • Test the connection by running:
npx prisma db pull

✔️ Command ran without errors

Your database connection is working. Since the database has no tables yet, you will not see any models added to your schema. That is expected.

ⓧ I see a connection error

  • Double-check that your DATABASE_URL in .env is the full connection string from Neon (starting with postgresql://).
  • Make sure there are no extra spaces or line breaks inside the connection string.
  • Confirm your Neon project is active by visiting the Neon console and checking that the project shows a green status.

help me fix Prisma connection errors to my Neon database

You will use the Thunder Client extension (already installed in VS Code) to test your API endpoints once the server is running in later steps.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both .env and prisma/schema.prisma.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

{
  "name": "task-api",
  "version": "1.0.0",
  "description": "Production-ready Task Management API",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "@prisma/client": "7.8.0",
    "bcryptjs": "3.0.3",
    "cors": "2.8.6",
    "dotenv": "16.4.7",
    "express": "4.22.2",
    "express-rate-limit": "8.5.2",
    "helmet": "8.2.0",
    "jsonwebtoken": "9.0.3",
    "zod": "3.24.4"
  },
  "devDependencies": {
    "nodemon": "3.1.11",
    "prisma": "7.8.0"
  }
}
PORT=3000
DATABASE_URL=your-neon-connection-string-here
JWT_SECRET=your-super-secret-key-change-this-in-production-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this-in-production-min-32-chars
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Your project foundation is locked in. You have a clean folder structure, all dependencies installed, a live PostgreSQL database on Neon, and Prisma connected and ready. Next up, you will define your database schema and build the authentication system.

Define the Database Schema and Build Authentication

Your environment is ready and connected to a live PostgreSQL database on Neon. Now it's time to define the data your API stores and build the system that controls who can access it.

Every production API needs two foundational layers: a database schema that defines the shape of your data, and an authentication system that verifies who is making each request. You will build both in this step, starting with Prisma models for Users and Tasks, then creating a full JWT authentication service.

In this step, get ready to:
  • Define User and Task models in the Prisma schema and run a database migration.
  • Build a JWT authentication service with register, login, and token refresh.
  • Create controller and route files to expose authentication endpoints.
Define the Prisma schema with User and Task models

Your Prisma schema is the single source of truth for your database structure. You will define two models: a User that holds account data and credentials, and a Task that belongs to a specific user.

  • In VS Code, open prisma/schema.prisma from the Explorer sidebar.
  • Replace the entire file contents with the following schema:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Define user roles
enum Role {
  USER
  ADMIN
}

// Define possible task statuses
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

// Define priority levels
enum Priority {
  LOW
  MEDIUM
  HIGH
}

// User model stores account and auth data
model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String
  password  String
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  tasks     Task[]
}

// Task model belongs to a user
model Task {
  id          String     @id @default(uuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  userId      String
  user        User       @relation(fields: [userId], references: [id])
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
}

What does this schema do?

The three enum blocks define the allowed values for roles, task statuses, and priorities. Prisma enforces these at the database level, so invalid values can never sneak in.

The User model stores each account's email, hashed password, and role. The Task model links back to its owner via userId and the @relation directive. This one-to-many relationship means each user can have many tasks, but each task belongs to exactly one user.

  • Save prisma/schema.prisma.
  • Apply this schema to your Neon database by running the migration command:
npx prisma migrate dev --name init

What does this command do?

The migrate dev command reads your schema, generates SQL to create the User and Task tables (plus enums), and applies that SQL to your Neon database. It saves the migration file under prisma/migrations/ so you have a versioned history of every database change.

You should see output confirming that the migration was created and applied to the database.

  • After the migration finishes, generate the Prisma Client by running:
npx prisma generate

Why run prisma generate separately?

In Prisma 7.x, the migrate dev command no longer triggers prisma generate automatically. You need to run it explicitly to create the type-safe client your application code will use to query the database.

Migration failing?

  • Check that your DATABASE_URL in .env is the full connection string from Neon (starts with postgresql://).
  • Make sure you saved prisma/schema.prisma before running the command.
  • If you see a network error, confirm your internet connection is active.

Still stuck? Help me fix my Prisma migration error

✔️ Awesome, I've got everything!

Great. Make sure you saved prisma/schema.prisma and that the migration applied without errors.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  USER
  ADMIN
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

enum Priority {
  LOW
  MEDIUM
  HIGH
}

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String
  password  String
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  tasks     Task[]
}

model Task {
  id          String     @id @default(uuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  userId      String
  user        User       @relation(fields: [userId], references: [id])
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
}
Build the authentication service

With the database ready, you need a way to create user accounts, verify credentials, and issue tokens that prove identity on every request. You will build this as a layered system: a service handles the logic, a controller handles HTTP requests, and a route file maps URLs to the controller.

What is a layered architecture?

Instead of putting all logic in one file, production APIs split responsibilities into layers. Routes define which URL triggers which handler. Controllers parse the request and send the response. Services contain the actual business logic (hashing passwords, querying the database, signing tokens).

This separation makes code easier to test, reuse, and maintain as the project grows.

First, you need a shared Prisma Client instance that all services can import.

  • Create a new file src/utils/prisma.js and add the following code:
const { PrismaClient } = require('@prisma/client');

// Single shared instance for the entire app
const prisma = new PrismaClient();

module.exports = prisma;

What does this file do?

Creating one PrismaClient instance and exporting it means every file that needs database access imports the same connection. This avoids opening multiple database connections, which would exhaust your connection pool.

  • Save src/utils/prisma.js.

Now build the authentication service that handles registration, login, and token refresh.

  • Create a new file src/services/auth.service.js and add the following code:
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const prisma = require('../utils/prisma');

// Register a new user account
const register = async (email, name, password) => {
  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    const error = new Error('Email already registered');
    error.statusCode = 409;
    throw error;
  }

  // Hash the password with 12 salt rounds
  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await prisma.user.create({
    data: { email, name, password: hashedPassword },
    select: { id: true, email: true, name: true, role: true },
  });

  const tokens = generateTokens(user);
  return { user, ...tokens };
};

// Authenticate an existing user
const login = async (email, password) => {
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    const error = new Error('Invalid email or password');
    error.statusCode = 401;
    throw error;
  }

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    const error = new Error('Invalid email or password');
    error.statusCode = 401;
    throw error;
  }

  const userData = { id: user.id, email: user.email, name: user.name, role: user.role };
  const tokens = generateTokens(userData);
  return { user: userData, ...tokens };
};

// Issue new tokens using a valid refresh token
const refresh = async (refreshToken) => {
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const user = await prisma.user.findUnique({
      where: { id: payload.sub },
      select: { id: true, email: true, name: true, role: true },
    });

    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 401;
      throw error;
    }

    const tokens = generateTokens(user);
    return { user, ...tokens };
  } catch (err) {
    if (err.statusCode) throw err;
    const error = new Error('Invalid refresh token');
    error.statusCode = 401;
    throw error;
  }
};

// Helper to create access and refresh token pair
const generateTokens = (user) => {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { sub: user.id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { accessToken, refreshToken };
};

module.exports = { register, login, refresh };

What does this service do?

  • register checks for duplicate emails, hashes the password with bcryptjs (12 salt rounds), creates the user in the database, and returns tokens.
  • login finds the user by email, compares the hashed password, and returns tokens on success. It uses the same error message for both wrong email and wrong password so attackers cannot guess which field was incorrect.
  • generateTokens creates a short-lived access token (15 minutes) and a long-lived refresh token (7 days). When the access token expires, the client uses the refresh token to get a new pair without re-entering credentials.
  • Save src/services/auth.service.js.

Seeing a module not found error later?

If you see an error about @prisma/client not being found when you run the app, make sure you ran npx prisma generate after the migration. This generates the client code that src/utils/prisma.js imports.

Help me debug a Prisma Client import error

✔️ Awesome, I've got everything!

Great. Double check that you saved both src/utils/prisma.js and src/services/auth.service.js.

ⓧ I'd like to double check the full code

const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const prisma = require('../utils/prisma');

const register = async (email, name, password) => {
  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) {
    const error = new Error('Email already registered');
    error.statusCode = 409;
    throw error;
  }

  const hashedPassword = await bcrypt.hash(password, 12);
  const user = await prisma.user.create({
    data: { email, name, password: hashedPassword },
    select: { id: true, email: true, name: true, role: true },
  });

  const tokens = generateTokens(user);
  return { user, ...tokens };
};

const login = async (email, password) => {
  const user = await prisma.user.findUnique({ where: { email } });
  if (!user) {
    const error = new Error('Invalid email or password');
    error.statusCode = 401;
    throw error;
  }

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    const error = new Error('Invalid email or password');
    error.statusCode = 401;
    throw error;
  }

  const userData = { id: user.id, email: user.email, name: user.name, role: user.role };
  const tokens = generateTokens(userData);
  return { user: userData, ...tokens };
};

const refresh = async (refreshToken) => {
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const user = await prisma.user.findUnique({
      where: { id: payload.sub },
      select: { id: true, email: true, name: true, role: true },
    });

    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 401;
      throw error;
    }

    const tokens = generateTokens(user);
    return { user, ...tokens };
  } catch (err) {
    if (err.statusCode) throw err;
    const error = new Error('Invalid refresh token');
    error.statusCode = 401;
    throw error;
  }
};

const generateTokens = (user) => {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  const refreshToken = jwt.sign(
    { sub: user.id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  return { accessToken, refreshToken };
};

module.exports = { register, login, refresh };

Next, create the controller that handles incoming HTTP requests and passes them to the auth service.

  • Create a new file src/controllers/auth.controller.js and add the following code:
const authService = require('../services/auth.service');

// Handle user registration requests
const register = async (req, res, next) => {
  try {
    const { email, name, password } = req.body;
    const result = await authService.register(email, name, password);
    res.status(201).json(result);
  } catch (err) {
    next(err);
  }
};

// Handle login requests
const login = async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const result = await authService.login(email, password);
    res.json(result);
  } catch (err) {
    next(err);
  }
};

// Handle token refresh requests
const refresh = async (req, res, next) => {
  try {
    const { refreshToken } = req.body;
    const result = await authService.refresh(refreshToken);
    res.json(result);
  } catch (err) {
    next(err);
  }
};

module.exports = { register, login, refresh };

What does this controller do?

Each controller function extracts what it needs from req.body, calls the service, and sends a response. Errors are forwarded to Express's error handler via next(err) instead of being handled inline. This keeps the controller thin and focused on HTTP concerns only.

  • Save src/controllers/auth.controller.js.

Finally, create the route file that maps URL paths to controller handlers.

  • Create a new file src/routes/auth.routes.js and add the following code:
const { Router } = require('express');
const authController = require('../controllers/auth.controller');
const validate = require('../middleware/validate');
const { registerSchema, loginSchema, refreshSchema } = require('../utils/schemas');
const { authLimiter } = require('../middleware/rateLimiter');

const router = Router();

// POST /register - create a new user account
router.post('/register', authLimiter, validate(registerSchema), authController.register);
// POST /login - authenticate and receive tokens
router.post('/login', authLimiter, validate(loginSchema), authController.login);
// POST /refresh - exchange refresh token for new token pair
router.post('/refresh', validate(refreshSchema), authController.refresh);

module.exports = router;

What does this route file do?

Each route maps a POST endpoint to its controller handler. The authLimiter and validate middleware referenced in the imports will be created in a later step. You are defining the complete route structure now so it is ready when those middleware files are added.

The /register and /login routes both use a rate limiter to prevent brute-force attacks. The /refresh route skips rate limiting because it requires a valid token to call.

  • Save src/routes/auth.routes.js.
  • Confirm all four new files exist by running:
ls src/utils/ src/services/ src/controllers/ src/routes/

You should see prisma.js in utils, auth.service.js in services, auth.controller.js in controllers, and auth.routes.js in routes.

File not showing up?

  • Make sure you created the file in the correct subdirectory. For example, auth.service.js goes in src/services/, not in src/ directly.
  • Check for typos in the filename. JavaScript require() is case-sensitive.

Help me find where my files went

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved and you can see them in the VS Code Explorer sidebar.

ⓧ I'd like to double check the full code

const authService = require('../services/auth.service');

const register = async (req, res, next) => {
  try {
    const { email, name, password } = req.body;
    const result = await authService.register(email, name, password);
    res.status(201).json(result);
  } catch (err) {
    next(err);
  }
};

const login = async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const result = await authService.login(email, password);
    res.json(result);
  } catch (err) {
    next(err);
  }
};

const refresh = async (req, res, next) => {
  try {
    const { refreshToken } = req.body;
    const result = await authService.refresh(refreshToken);
    res.json(result);
  } catch (err) {
    next(err);
  }
};

module.exports = { register, login, refresh };
const { Router } = require('express');
const authController = require('../controllers/auth.controller');
const validate = require('../middleware/validate');
const { registerSchema, loginSchema, refreshSchema } = require('../utils/schemas');
const { authLimiter } = require('../middleware/rateLimiter');

const router = Router();

router.post('/register', authLimiter, validate(registerSchema), authController.register);
router.post('/login', authLimiter, validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh);

module.exports = router;

Your database schema is live on Neon and your authentication logic is ready. Next up, you will build the task CRUD endpoints and wire everything together into a running Express server.

Build Task CRUD with Per-User Isolation

Your authentication system is ready. Users can register, log in, and receive tokens. Now you need to give those authenticated users something to do.

In this step, you will build the core task endpoints that let each user create, read, update, and delete their own tasks. The critical pattern here is per-user isolation. Every database query filters by the logged-in user's ID, so one user can never see or modify another user's data.

In this step, get ready to:
  • Build a task service layer with per-user data isolation.
  • Create task controller and routes protected by auth middleware.
  • Wire the Express app together and test all endpoints with Thunder Client.
Build the task service layer

The service layer handles all database logic for tasks. Every function accepts a userId parameter and uses it to filter queries. This enforces per-user isolation at the data layer.

  • Create a new file src/services/task.service.js and add the following code:
const prisma = require('../utils/prisma');

// Create a new task linked to the authenticated user
const create = async (userId, data) => {
  return prisma.task.create({
    data: { ...data, userId },
  });
};

// Fetch all tasks belonging to this user, newest first
const findAll = async (userId) => {
  return prisma.task.findMany({
    where: { userId },
    orderBy: { createdAt: 'desc' },
  });
};

// Find a single task only if the user owns it
const findById = async (userId, taskId) => {
  const task = await prisma.task.findUnique({ where: { id: taskId } });
  if (!task || task.userId !== userId) {
    return null;
  }
  return task;
};

// Update a task only if the user owns it
const update = async (userId, taskId, data) => {
  const task = await findById(userId, taskId);
  if (!task) return null;

  return prisma.task.update({
    where: { id: taskId },
    data,
  });
};

// Delete a task only if the user owns it
const remove = async (userId, taskId) => {
  const task = await findById(userId, taskId);
  if (!task) return null;

  await prisma.task.delete({ where: { id: taskId } });
  return true;
};

module.exports = { create, findAll, findById, update, remove };

What does this code do?

Every function takes userId as its first argument. This is the key to per-user isolation.

  • create attaches the userId to the new task record so it belongs to the authenticated user.
  • findAll uses where: { userId } so the database only returns tasks that belong to this specific user.
  • findById, update, and remove all verify ownership before acting. If the task belongs to a different user, they return null instead of exposing or modifying the data.
  • Save src/services/task.service.js.

Why per-user isolation at the service layer?

Putting isolation logic in the service layer means every route that calls these functions automatically inherits the protection. You cannot accidentally expose another user's tasks by forgetting a filter in a new controller method.

Seeing a red underline on prisma?

Make sure src/utils/prisma.js exists from the previous step. The import path ../utils/prisma must resolve to that file.

Help me debug a missing prisma import in my task service.

Build the task controller and routes

Before the task routes can work, you need middleware that verifies the user's JWT token and attaches their identity to the request. Then the controller and routes can use that identity to call the service layer.

  • Create a new file src/middleware/auth.middleware.js and add the following code:
const jwt = require('jsonwebtoken');

// Middleware that verifies the Bearer token from the Authorization header
const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' });
  }

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

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

module.exports = authenticate;

What does this middleware do?

This function runs before any protected route. It extracts the token from the Authorization: Bearer <token> header, verifies it with JWT_SECRET, and attaches the decoded payload (containing sub, email, role) to req.user.

Downstream controllers then access req.user.sub to get the authenticated user's ID. If the token is missing or invalid, the request is rejected with a 401 status before it ever reaches the controller.

  • Save src/middleware/auth.middleware.js.
  • Create a new file src/controllers/task.controller.js and add the following code:
const taskService = require('../services/task.service');

// Create a new task for the authenticated user
const create = async (req, res, next) => {
  try {
    const task = await taskService.create(req.user.sub, req.body);
    res.status(201).json(task);
  } catch (err) {
    next(err);
  }
};

// List all tasks for the authenticated user
const findAll = async (req, res, next) => {
  try {
    const tasks = await taskService.findAll(req.user.sub);
    res.json(tasks);
  } catch (err) {
    next(err);
  }
};

// Get a single task by ID (only if the user owns it)
const findById = async (req, res, next) => {
  try {
    const task = await taskService.findById(req.user.sub, req.params.id);
    if (!task) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
  } catch (err) {
    next(err);
  }
};

// Update a task (only if the user owns it)
const update = async (req, res, next) => {
  try {
    const task = await taskService.update(req.user.sub, req.params.id, req.body);
    if (!task) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
  } catch (err) {
    next(err);
  }
};

// Delete a task (only if the user owns it)
const remove = async (req, res, next) => {
  try {
    const result = await taskService.remove(req.user.sub, req.params.id);
    if (!result) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.status(204).send();
  } catch (err) {
    next(err);
  }
};

module.exports = { create, findAll, findById, update, remove };

What does this code do?

Each controller function follows the same pattern: call the service with req.user.sub (the user's ID from the JWT), then return the appropriate HTTP status code.

  • 201 for successful creation.
  • 200 (default res.json()) for successful reads and updates.
  • 404 when a task is not found or the user does not own it.
  • 204 for successful deletion (no content to return).
  • Save src/controllers/task.controller.js.
  • Create a new file src/routes/task.routes.js and add the following code:
const { Router } = require('express');
const taskController = require('../controllers/task.controller');
const authenticate = require('../middleware/auth.middleware');
const validate = require('../middleware/validate');
const { createTaskSchema, updateTaskSchema } = require('../utils/schemas');

const router = Router();

// Apply auth middleware to ALL task routes
router.use(authenticate);

// Task CRUD endpoints
router.post('/', validate(createTaskSchema), taskController.create);
router.get('/', taskController.findAll);
router.get('/:id', taskController.findById);
router.put('/:id', validate(updateTaskSchema), taskController.update);
router.delete('/:id', taskController.remove);

module.exports = router;

What does this code do?

router.use(authenticate) applies the auth middleware to every route defined below it. This means every task endpoint requires a valid JWT. No request reaches the controller without first proving the user's identity.

The validate middleware (created in the previous step) checks request bodies against Zod schemas before the controller runs.

  • Save src/routes/task.routes.js.

Seeing a Cannot find module error?

Double check that src/middleware/auth.middleware.js and src/middleware/validate.js both exist. The import paths in task.routes.js are relative, so the files must be in exactly those locations.

Help me fix a Cannot find module error in my task routes.

Wire everything together in the main app

You have the service, controller, and routes ready. Now you need an Express app that registers these routes and a server entry point that starts everything up.

  • Create a new file src/middleware/error.middleware.js and add the following code:
// Global error handler - catches errors passed via next(err)
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);

  // Handle Prisma unique constraint violations
  if (err.code === 'P2002') {
    return res.status(409).json({ error: 'A record with this value already exists' });
  }

  // Return consistent JSON error responses without exposing stack traces
  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};

module.exports = errorHandler;

What does this code do?

This is a global error handler. When any controller calls next(err), Express routes the error here. It logs the full stack trace for debugging but only sends a clean JSON message to the client. This prevents leaking internal details to API consumers.

  • Save src/middleware/error.middleware.js.
  • Create a new file src/app.js and add the following code:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { globalLimiter } = require('./middleware/rateLimiter');
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');
const errorHandler = require('./middleware/error.middleware');

const app = express();

// Security and parsing middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(globalLimiter);

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

// Register route modules under /api prefix
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

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

module.exports = app;

What does this code do?

This file assembles the full Express application. It applies global middleware (Helmet for security headers, CORS for cross-origin requests, JSON body parsing, and rate limiting), then mounts the auth and task route modules under the /api prefix.

The health endpoint at GET /api/health gives you a quick way to confirm the server is running. The error handler is registered last so it catches errors from all routes above it.

  • Save src/app.js.
  • Create a new file src/server.js and add the following code:
require('dotenv').config();
const app = require('./app');

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

This is the entry point. It loads environment variables from .env using dotenv, then imports the configured app and starts listening on port 3000. Keeping app.js and server.js separate makes the app easier to test without actually starting a server.

  • Save src/server.js.
  • Start the development server by running this command in your terminal:
npm run dev
  • You should see Server running on port 3000 in the terminal output.

Server crashing on startup?

If you see a Cannot find module error, check that all the files you created are in the correct directories. The src/middleware/, src/routes/, src/controllers/, and src/services/ folders must all exist with the files you just created.

If you see a database connection error, confirm your DATABASE_URL in .env is correct and that Neon is accessible.

Help me debug why my Express server is crashing on startup.

Now test the full flow with Thunder Client. Open the Thunder Client extension in VS Code (click the lightning bolt icon in the sidebar).

  • 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 this body:
{
  "email": "test@example.com",
  "name": "Test User",
  "password": "password123"
}
  • Click Send. You should receive a 201 response with a user object, an accessToken, and a refreshToken.
  • Copy the accessToken value from the response.
  • Create another new request. Set the method to POST and the URL to http://localhost:3000/api/tasks.
  • Click the Auth tab, select Bearer, and paste your access token.
  • Click the Body tab, select JSON, and enter this body:
{
  "title": "Build a REST API",
  "description": "Complete the task management project",
  "priority": "HIGH"
}
  • Click Send. You should receive a 201 response with the new task object including an id, userId, and default status of TODO.
  • Create one more request. Set the method to GET and the URL to http://localhost:3000/api/tasks.
  • Add the same Bearer token in the Auth tab and click Send.
  • You should see an array containing the task you just created.

What should I see?

The GET response should be a JSON array with one task. The task has all the fields from your Prisma schema: id, title, description, status, priority, userId, createdAt, and updatedAt. If you try the same GET request without the Bearer token, you should receive a 401 error confirming that authentication is enforced.

Getting a 401 on the task endpoints?

Access tokens expire after 15 minutes. If you waited too long between registering and creating tasks, log in again at POST /api/auth/login and use the fresh token.

Also check that you selected Bearer (not Basic) in Thunder Client's Auth tab, and that the token is pasted without extra spaces.

Help me fix a 401 error when calling my task endpoints.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: auth.middleware.js, task.service.js, task.controller.js, task.routes.js, error.middleware.js, app.js, and server.js.

ⓧ I'd like to double check the full code

Here is the full code for each file created in this step:

const jwt = require('jsonwebtoken');

const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' });
  }

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

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
};

module.exports = authenticate;
const prisma = require('../utils/prisma');

const create = async (userId, data) => {
  return prisma.task.create({
    data: { ...data, userId },
  });
};

const findAll = async (userId) => {
  return prisma.task.findMany({
    where: { userId },
    orderBy: { createdAt: 'desc' },
  });
};

const findById = async (userId, taskId) => {
  const task = await prisma.task.findUnique({ where: { id: taskId } });
  if (!task || task.userId !== userId) {
    return null;
  }
  return task;
};

const update = async (userId, taskId, data) => {
  const task = await findById(userId, taskId);
  if (!task) return null;

  return prisma.task.update({
    where: { id: taskId },
    data,
  });
};

const remove = async (userId, taskId) => {
  const task = await findById(userId, taskId);
  if (!task) return null;

  await prisma.task.delete({ where: { id: taskId } });
  return true;
};

module.exports = { create, findAll, findById, update, remove };
const taskService = require('../services/task.service');

const create = async (req, res, next) => {
  try {
    const task = await taskService.create(req.user.sub, req.body);
    res.status(201).json(task);
  } catch (err) {
    next(err);
  }
};

const findAll = async (req, res, next) => {
  try {
    const tasks = await taskService.findAll(req.user.sub);
    res.json(tasks);
  } catch (err) {
    next(err);
  }
};

const findById = async (req, res, next) => {
  try {
    const task = await taskService.findById(req.user.sub, req.params.id);
    if (!task) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
  } catch (err) {
    next(err);
  }
};

const update = async (req, res, next) => {
  try {
    const task = await taskService.update(req.user.sub, req.params.id, req.body);
    if (!task) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
  } catch (err) {
    next(err);
  }
};

const remove = async (req, res, next) => {
  try {
    const result = await taskService.remove(req.user.sub, req.params.id);
    if (!result) {
      return res.status(404).json({ error: 'Task not found' });
    }
    res.status(204).send();
  } catch (err) {
    next(err);
  }
};

module.exports = { create, findAll, findById, update, remove };
const { Router } = require('express');
const taskController = require('../controllers/task.controller');
const authenticate = require('../middleware/auth.middleware');
const validate = require('../middleware/validate');
const { createTaskSchema, updateTaskSchema } = require('../utils/schemas');

const router = Router();

router.use(authenticate);

router.post('/', validate(createTaskSchema), taskController.create);
router.get('/', taskController.findAll);
router.get('/:id', taskController.findById);
router.put('/:id', validate(updateTaskSchema), taskController.update);
router.delete('/:id', taskController.remove);

module.exports = router;
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);

  if (err.code === 'P2002') {
    return res.status(409).json({ error: 'A record with this value already exists' });
  }

  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};

module.exports = errorHandler;
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { globalLimiter } = require('./middleware/rateLimiter');
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');
const errorHandler = require('./middleware/error.middleware');

const app = express();

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

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

app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

app.use(errorHandler);

module.exports = app;
require('dotenv').config();
const app = require('./app');

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Your API now has fully protected task CRUD endpoints with per-user isolation. Next up, you will add input validation, security headers, and rate limiting to harden the API for production.

Add Security and Production Middleware

Your API works. Users can register, log in, and manage tasks. But right now, nothing stops someone from sending nonsense data or hammering your endpoints with thousands of requests per second.

In this step, you'll add the security middleware that separates a toy project from a production-ready API: input validation with Zod, rate limiting to block abuse, security headers with Helmet, and a global error handler that keeps your stack traces private.

In this step, get ready to:
  • Add input validation with Zod schemas.
  • Add rate limiting and security headers.
  • Test the complete security stack with Thunder Client.
Add input validation with Zod

Right now, a user could send a registration request with an empty password or a task with no title, and your API would try to save it. Validation middleware intercepts bad data before it reaches your service layer.

  • Create a new file src/middleware/validate.js and add the following code:
const validate = (schema) => (req, res, next) => {
  // Run the request body through the Zod schema
  const result = schema.safeParse(req.body);
  if (!result.success) {
    // Map Zod errors into a clean field + message format
    const errors = result.error.errors.map((err) => ({
      field: err.path.join('.'),
      message: err.message,
    }));
    return res.status(400).json({ error: 'Validation failed', details: errors });
  }
  // Replace req.body with the parsed (and type-safe) data
  req.body = result.data;
  next();
};

module.exports = validate;

What does this code do?

This is a higher-order function. It takes a Zod schema and returns Express middleware.

  • schema.safeParse(req.body) validates the request body without throwing an error. It returns a result object with success and data or error properties.
  • On failure, it returns a 400 response with field-level error details so the client knows exactly what to fix.
  • On success, it replaces req.body with the parsed data. This strips out any extra fields the client sent that aren't in the schema.
  • Save src/middleware/validate.js.

You won't see a change yet. The validate function needs schemas to work with, which you'll create next.

  • Create a new file src/utils/schemas.js and add the following code:
const { z } = require('zod');

// Schema for user registration
const registerSchema = z.object({
  email: z.string().email('Invalid email address'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

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

// Schema for token refresh
const refreshSchema = z.object({
  refreshToken: z.string().min(1, 'Refresh token is required'),
});

// Schema for creating a new task
const createTaskSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  description: z.string().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});

// Schema for updating an existing task (all fields optional)
const updateTaskSchema = z.object({
  title: z.string().min(1).optional(),
  description: z.string().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});

module.exports = {
  registerSchema,
  loginSchema,
  refreshSchema,
  createTaskSchema,
  updateTaskSchema,
};

What does this code do?

Each schema defines the exact shape that a request body must match. Zod enforces types, minimum lengths, and allowed values at runtime.

  • registerSchema requires a valid email, a name of at least 2 characters, and a password of at least 8 characters.
  • createTaskSchema requires a title. Status and priority are optional because the Prisma schema provides defaults (TODO and MEDIUM).
  • updateTaskSchema makes every field optional. A PUT request might only update the status without touching the title.
  • z.enum() restricts values to the exact strings your database expects. Sending status: "COMPLETE" would fail validation before it ever hits Prisma.
  • Save src/utils/schemas.js.

Your route files already import and apply these schemas. The validation middleware is now active on all auth and task routes.

How are these connected to your routes?

Open src/routes/auth.routes.js. You'll see validate(registerSchema) and validate(loginSchema) already applied as middleware on the register and login routes.

Similarly, src/routes/task.routes.js uses validate(createTaskSchema) on POST and validate(updateTaskSchema) on PUT. Every request passes through schema validation before reaching your controller.

Seeing a module not found error?

Make sure you saved both files in the correct locations. validate.js goes in src/middleware/ and schemas.js goes in src/utils/. The folder names must match the import paths in your route files exactly.

help me fix a module not found error in my Express middleware imports

Add the security middleware stack

Validation catches bad data. Rate limiting catches bad actors. Together with security headers and a global error handler, they form a defense-in-depth strategy that protects your API from multiple angles.

  • Create a new file src/middleware/rateLimiter.js and add the following code:
const rateLimit = require('express-rate-limit');

// Global limiter: 100 requests per 15-minute window
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

// Stricter limiter for auth endpoints: 5 requests per 15-minute window
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Too many auth attempts, please try again later.' },
});

module.exports = { globalLimiter, authLimiter };

What does this code do?

You're creating two rate limiters with different thresholds.

  • globalLimiter allows 100 requests per 15 minutes from a single IP. This applies to every endpoint.
  • authLimiter allows only 5 requests per 15 minutes. This protects login and register from brute-force attacks.
  • standardHeaders: 'draft-8' sends standard RateLimit response headers so clients know how many requests they have left.
  • legacyHeaders: false disables the older X-RateLimit-* headers. The standard headers are the modern replacement.

Why two separate limiters?

Authentication endpoints are the most dangerous surface to leave unprotected. An attacker trying to guess passwords needs thousands of attempts. Limiting auth routes to 5 requests per window makes brute-force attacks impractical without affecting normal API usage.

  • Save src/middleware/rateLimiter.js.

You won't see a visible change yet. The next file completes the middleware stack.

  • Create a new file src/middleware/error.middleware.js and add the following code:
const errorHandler = (err, req, res, next) => {
  // Log the full error for debugging (server-side only)
  console.error(err.stack);

  // Handle Prisma unique constraint violations
  if (err.code === 'P2002') {
    return res.status(409).json({ error: 'A record with this value already exists' });
  }

  // Return a clean error response without exposing internals
  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};

module.exports = errorHandler;

What does this code do?

This is your safety net. Any error that isn't caught by a controller's try/catch flows here.

  • console.error(err.stack) logs the full stack trace on the server so you can debug issues.
  • The Prisma P2002 check handles unique constraint violations (like registering the same email twice) and returns a clear 409 Conflict response.
  • The final res.status() line uses the custom statusCode you set in your services, or defaults to 500. Clients never see raw stack traces or internal details.
  • Save src/middleware/error.middleware.js.

How does app.js wire these together?

Open src/app.js. You'll see the middleware applied in a specific order that matters:

  • helmet() runs first, adding security headers (Content-Security-Policy, X-Content-Type-Options, and more) to every response.
  • cors() enables Cross-Origin Resource Sharing so frontend apps on different domains can call your API.
  • globalLimiter applies the 100 req/15min limit to all routes.
  • errorHandler is registered last (after routes) so it catches any unhandled errors from the entire request pipeline.
  • Restart your dev server by pressing Ctrl+C in the terminal, then running:
npm run dev

Your server should start without errors. If you see Server running on port 3000, all middleware files are loading correctly.

Server crashing on startup?

Check that all four new files are saved in the correct folders. The server needs src/middleware/validate.js, src/middleware/rateLimiter.js, src/middleware/error.middleware.js, and src/utils/schemas.js to exist before it can start.

help me debug why my Express server crashes on startup with a module not found error

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on to testing.

ⓧ I'd like to double check the full code

Here are the four new files you created in this step:

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    const errors = result.error.errors.map((err) => ({
      field: err.path.join('.'),
      message: err.message,
    }));
    return res.status(400).json({ error: 'Validation failed', details: errors });
  }
  req.body = result.data;
  next();
};

module.exports = validate;
const { z } = require('zod');

const registerSchema = z.object({
  email: z.string().email('Invalid email address'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

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

const refreshSchema = z.object({
  refreshToken: z.string().min(1, 'Refresh token is required'),
});

const createTaskSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  description: z.string().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});

const updateTaskSchema = z.object({
  title: z.string().min(1).optional(),
  description: z.string().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});

module.exports = {
  registerSchema,
  loginSchema,
  refreshSchema,
  createTaskSchema,
  updateTaskSchema,
};
const rateLimit = require('express-rate-limit');

const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Too many auth attempts, please try again later.' },
});

module.exports = { globalLimiter, authLimiter };
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);

  if (err.code === 'P2002') {
    return res.status(409).json({ error: 'A record with this value already exists' });
  }

  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal server error',
  });
};

module.exports = errorHandler;
Test the complete API

Your middleware stack is in place. Time to verify that validation, rate limiting, security headers, and per-user isolation all work correctly.

  • Open Thunder Client in VS Code by clicking the lightning bolt icon in the left sidebar.
  • Create a new POST request to http://localhost:3000/api/auth/register.
  • In the Body tab, select JSON and enter this invalid payload to test validation:
{
  "email": "not-an-email",
  "name": "A",
  "password": "short"
}
  • Click Send.

You should see a 400 response with field-level error details explaining that the email is invalid, name is too short, and password is too short.

What should I see?

The response body should look like this:

{
  "error": "Validation failed",
  "details": [
    { "field": "email", "message": "Invalid email address" },
    { "field": "name", "message": "Name must be at least 2 characters" },
    { "field": "password", "message": "Password must be at least 8 characters" }
  ]
}
  • Now test security headers. Send a GET request to http://localhost:3000/api/health.
  • Click the Headers tab in the response panel.

You should see security headers like content-security-policy, x-content-type-options, and x-frame-options in the response. These are all added automatically by Helmet.

  • Test rate limiting by sending the POST to /api/auth/register (with a valid body) 6 times rapidly.

After 5 requests, the 6th should return a 429 status with the message Too many auth attempts, please try again later..

  • Finally, test per-user isolation. Register a second user with a different email, log in as that user, and try to GET /api/tasks using the second user's access token.

The second user should see an empty task list, even though the first user has tasks in the database. Each user only sees their own data.

Not seeing the rate limit trigger?

The auth limiter allows 5 requests per 15-minute window. Make sure you're sending all requests to an auth endpoint (/api/auth/register or /api/auth/login), not to the general /api/tasks endpoint which has the higher 100-request global limit.

help me test rate limiting in my Express API

Your API now has a complete security stack. Invalid data gets rejected with helpful messages. Abusive traffic gets throttled. Error details stay server-side. Next up, you'll extend this API with pagination and filtering in the Secret Mission.

Secret mission

Add Pagination, Filtering, and Sorting

Your API returns all tasks in a single response. Production APIs with hundreds or thousands of records need pagination, filtering, and sorting. In this mission, you'll extend the GET /tasks endpoint with query parameters that let clients request exactly the data they need.

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 Neon's free tier for the database, which scales to zero after 5 minutes of inactivity and costs nothing while idle.

Resources you used:

  • Neon PostgreSQL database (free tier, scales to zero when idle).
  • Local task-api project folder on your Desktop.
  • Thunder Client VS Code extension.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your API as a portfolio piece.

  • Your Neon database costs nothing while idle. It automatically scales to zero after 5 minutes of inactivity.
  • Your local task-api folder stays on your Desktop for future reference.
  • Thunder Client stays installed in VS Code for testing future APIs.

Pause - I'll come back to this later

Stop any running processes to free up system resources. Your files and database stay intact.

  • In your terminal, press Ctrl+C to stop the development server if it is still running.
  • Your Neon database automatically pauses after 5 minutes of inactivity. No action needed there.
  • Your task-api folder and all code remain on your Desktop.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete your Neon project by navigating to the Neon Console.
  • Select the project you created for this tutorial.
  • Select Settings from the sidebar.
  • Click Delete at the bottom of the page and confirm. This permanently removes the database, all branches, and all data.
  • Delete your local project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/task-api
  • On Windows, run this command instead:
Remove-Item -Recurse -Force ~\Desktop\task-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 list, click the gear icon, and select Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-ready Task Management API with Express.js, Prisma ORM, and PostgreSQL on Neon. This is a real backend that goes far beyond basic CRUD tutorials.

You've learned how to:

  • Build a REST API with JWT authentication (access and refresh tokens) and secure password hashing with bcryptjs.
  • Create task CRUD endpoints with per-user data isolation and a production security stack (Helmet, CORS, rate limiting, and Zod input validation).
  • Structure a project with a clean layered architecture (routes, controllers, services, middleware) that mirrors real production Node.js backends.
  • Secret Mission: Added pagination, filtering, and sorting with metadata response to the tasks endpoint.

Ready to quiz yourself?