Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 77

Secure REST API with User Authentication and CRUD Operations

Recommended resources

Hello! Welcome back to our capstone project.

In our last lesson, we acted as architects, designing the blueprint for our full-stack Task Manager. We established a clean, professional project structure with separate client and server directories and created the initial tables in our PostgreSQL database.

Today, we transition from architect to builder. Our goal is to bring the backend to life by constructing a complete and secure REST API. We will implement the core logic for user authentication (registration and login) and the full suite of CRUD (Create, Read, Update, Delete) operations for tasks. By the end of this lesson, you will have a fully functional backend service, ready to be connected to our React frontend.

We'll be working entirely within the server directory we set up, populating the routes, controllers, services, and db folders with the logic that will power our application.

The Backend Architecture in Action

Let's revisit the architectural pattern we're implementing. An incoming HTTP request will flow through a series of specialized layers, each with a distinct responsibility.

Node.js, Express.js, JWT Authentication, and PostgreSQL Architecture
This diagram shows the flow of an HTTP request through our backend. It passes through middleware (for CORS and authentication), is directed by the Router to a Controller, which uses a Service layer for business logic, and finally interacts with the PostgreSQL database. The response flows back through the same chain.

This lesson is all about building these pieces: the Routers that define our endpoints, the Controllers that handle HTTP logic, the Services that contain business logic, and the Data Access Layer that queries our database.


Part 1: Setting up the Database Connection

Before we can write any logic, our application needs a way to talk to the PostgreSQL database. We'll create a centralized connection pool. Using a pool is much more efficient than creating a new connection for every single query.

In your server/src/db/ directory, create a new file named index.js and add the following code:

const { Pool } = require('pg');

const pool = new Pool({
  user: process.env.DB_USER,
  host: process.env.DB_HOST,
  database: process.env.DB_DATABASE,
  password: process.env.DB_PASSWORD,
  port: process.env.DB_PORT,
});

module.exports = {
  query: (text, params) => pool.query(text, params),
};

This code creates a connection pool using the credentials from our environment variables (which we'll set up next) and exports a single query function. This provides a clean, consistent way for our entire application to run queries against the database.

Environment Variables

Create a .env file in the root of your server directory. This is where we'll store sensitive information like database credentials.

# server/.env

DB_USER=your_postgres_username
DB_HOST=localhost
DB_DATABASE=your_database_name  # The DB you created in the last lesson
DB_PASSWORD=your_postgres_password
DB_PORT=5432

JWT_SECRET=a_very_strong_and_long_secret_key_that_is_hard_to_guess

Important: Replace the placeholder values with your actual PostgreSQL credentials. Also, replace the JWT_SECRET with a long, random string. This secret is critical for securing your application's tokens.


Part 2: Implementing User Authentication

Secure authentication is the foundation of our application. We need to allow users to register and log in. This process involves password hashing with bcrypt to protect user credentials and JSON Web Tokens (JWT) to manage user sessions.

First, let's install the necessary packages. In your server directory, run:
npm install bcryptjs jsonwebtoken cookie-parser

The overall authentication flow looks like this:

JWT Authentication Flow with Node.js and PostgreSQL
This diagram illustrates the authentication flow. A user registers, then logs in. Upon successful login, the server issues a JWT. The client then includes this JWT in the header of subsequent requests to access protected resources.

We will now implement this flow within our layered architecture.

Step 1: Authentication Routes (routes/auth.routes.js)

This file defines the endpoints for registration and login. Create server/src/routes/auth.routes.js:

const { Router } = require('express');
const authController = require('../controllers/auth.controller');

const router = Router();

router.post('/register', authController.handleRegister);
router.post('/login', authController.handleLogin);
router.post('/logout', authController.handleLogout);

module.exports = router;

Step 2: Authentication Controller (controllers/auth.controller.js)

The controller is the bridge between the HTTP world and our application's services. It extracts data from the request, calls the appropriate service, and formats the response. Create server/src/controllers/auth.controller.js:

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

const handleRegister = async (req, res) => {
  try {
    const { username, password } = req.body;
    if (!username || !password) {
      return res.status(400).json({ message: 'Username and password are required.' });
    }
    const user = await authService.registerUser(username, password);
    res.status(201).json({ message: 'User registered successfully', user });
  } catch (error) {
    res.status(400).json({ message: error.message });
  }
};

const handleLogin = async (req, res) => {
  try {
    const { username, password } = req.body;
    if (!username || !password) {
      return res.status(400).json({ message: 'Username and password are required.' });
    }
    const { token, user } = await authService.loginUser(username, password);

    // Set the token in an HTTP-Only cookie for security
    res.cookie('token', token, {
      httpOnly: true, // Prevents client-side JS from accessing the cookie
      secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production
      maxAge: 24 * 60 * 60 * 1000 // 1 day
    });

    res.json({ message: 'Login successful', user });
  } catch (error) {
    res.status(401).json({ message: error.message });
  }
};

const handleLogout = (req, res) => {
  // Clear the cookie
  res.cookie('token', '', { httpOnly: true, expires: new Date(0) });
  res.status(200).json({ message: 'Logout successful' });
};

module.exports = {
  handleRegister,
  handleLogin,
  handleLogout,
};
Test your understanding!

In handleLogin, we set the token in an httpOnly cookie. What is the primary security benefit of using an httpOnly cookie instead of storing the JWT in localStorage on the client-side?

Show answer

An httpOnly cookie cannot be accessed by client-side JavaScript. This provides crucial protection against Cross-Site Scripting (XSS) attacks. If a malicious script were to run on your website, it could steal a JWT stored in localStorage, but it would be unable to access one stored in an httpOnly cookie, thus preventing the attacker from impersonating the user.

Step 3: Authentication Service (services/auth.service.js)

The service layer contains the core business logic. Here, we'll handle password hashing and JWT creation. Create server/src/services/auth.service.js:

const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userQueries = require('../db/user.queries');

const registerUser = async (username, password) => {
  const existingUser = await userQueries.findUserByUsername(username);
  if (existingUser) {
    throw new Error('Username already taken.');
  }

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

  const newUser = await userQueries.createUser(username, passwordHash);
  // Don't return the password hash
  delete newUser.password_hash; 
  return newUser;
};

const loginUser = async (username, password) => {
  const user = await userQueries.findUserByUsername(username);
  if (!user) {
    throw new Error('Invalid credentials.');
  }

  const isMatch = await bcrypt.compare(password, user.password_hash);
  if (!isMatch) {
    throw new Error('Invalid credentials.');
  }

  // Create JWT payload
  const payload = {
    id: user.id,
    username: user.username,
  };

  const token = jwt.sign(payload, process.env.JWT_SECRET, {
    expiresIn: '1d', // Token expires in 1 day
  });

  // Don't return the password hash
  delete user.password_hash;
  return { token, user };
};

module.exports = {
  registerUser,
  loginUser,
};

Step 4: Database Queries (db/user.queries.js)

This is the only layer that writes SQL. Create server/src/db/user.queries.js to handle user-related database operations.

const db = require('./index');

const findUserByUsername = async (username) => {
  const { rows } = await db.query('SELECT * FROM users WHERE username = $1', [username]);
  return rows[0];
};

const createUser = async (username, passwordHash) => {
  const { rows } = await db.query(
    'INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, role, created_at',
    [username, passwordHash]
  );
  return rows[0];
};

module.exports = {
  findUserByUsername,
  createUser,
};

Step 5: Authentication Middleware (middleware/auth.middleware.js)

To protect our task-related routes, we need a middleware that checks for a valid JWT on incoming requests. Create server/src/middleware/auth.middleware.js:

const jwt = require('jsonwebtoken');

const authenticateToken = (req, res, next) => {
  const token = req.cookies.token;

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

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Add the decoded user payload to the request object
    next(); // Pass control to the next handler
  } catch (error) {
    res.status(400).json({ message: 'Invalid token.' });
  }
};

module.exports = { authenticateToken };

To see this entire authentication flow explained and built step-by-step, the following video is an excellent resource. While it combines some layers, the core logic for hashing, token generation, and verification is identical to what we've just structured.

PERN Stack Authentication Tutorial | PostgreSQL, Express.js, React, Node.js

The 'PERN Stack Authentication Tutorial' video by Web Dev Bey provides a detailed walkthrough of building the authentication logic. We'll focus on the sections covering registration, login, and the protection middleware.

Watch the segment from 15:14 to 31:27. As you watch, notice how the logic in the video maps to the different files we just created (routes, controllers, services, db, and middleware). This will help solidify your understanding of how our layered architecture organizes the same set of functionalities.


Part 3: Implementing CRUD for Tasks

With authentication in place, we can now build the API endpoints for managing tasks. A key requirement is that users can only view, create, edit, and delete their own tasks. Our authenticateToken middleware makes this straightforward, as it provides the logged-in user's ID in req.user.id.

We will follow the same layered pattern as we did for authentication.

Step 1: Task Routes (routes/task.routes.js)

Create server/src/routes/task.routes.js. Notice how we apply the authenticateToken middleware to the entire router. This protects all task-related endpoints at once.

const { Router } = require('express');
const taskController = require('../controllers/task.controller');
const { authenticateToken } = require('../middleware/auth.middleware');

const router = Router();

// Apply the authentication middleware to all routes in this file
router.use(authenticateToken);

router.get('/', taskController.getAllTasks);
router.post('/', taskController.createTask);
router.get('/:id', taskController.getTaskById);
router.put('/:id', taskController.updateTask);
router.delete('/:id', taskController.deleteTask);

module.exports = router;

Step 2: Task Controller (controllers/task.controller.js)

Create server/src/controllers/task.controller.js. This file will orchestrate the handling of task-related requests.

const taskService = require('../services/task.service');

const getAllTasks = async (req, res) => {
  try {
    const userId = req.user.id; // From authenticateToken middleware
    const tasks = await taskService.getAllTasksForUser(userId);
    res.json(tasks);
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
};

const createTask = async (req, res) => {
  try {
    const userId = req.user.id;
    const { title, description } = req.body;
    const newTask = await taskService.createTaskForUser(title, description, userId);
    res.status(201).json(newTask);
  } catch (error) {
    res.status(400).json({ message: error.message });
  }
};

// ... Implement getTaskById, updateTask, deleteTask similarly ...

// Example for updateTask
const updateTask = async (req, res) => {
  try {
    const userId = req.user.id;
    const taskId = req.params.id;
    const updates = req.body; // { title, description, is_completed }
    const updatedTask = await taskService.updateUserTask(taskId, userId, updates);
    res.json(updatedTask);
  } catch (error) {
    res.status(404).json({ message: error.message });
  }
};

// Example for deleteTask
const deleteTask = async (req, res) => {
    try {
        const userId = req.user.id;
        const taskId = req.params.id;
        await taskService.deleteUserTask(taskId, userId);
        res.status(204).send(); // No Content
    } catch (error) {
        res.status(404).json({ message: error.message });
    }
};

// You can complete the remaining handlers as an exercise
const getTaskById = async (req, res) => {
    try {
        const userId = req.user.id;
        const taskId = req.params.id;
        const task = await taskService.getTaskByIdForUser(taskId, userId);
        res.json(task);
    } catch (error) {
        res.status(404).json({ message: error.message });
    }
};


module.exports = {
  getAllTasks,
  createTask,
  getTaskById,
  updateTask,
  deleteTask,
};

Note: We have provided the code for updateTask and deleteTask. Try implementing getTaskById yourself based on the pattern!

Step 3 & 4: Task Service and DB Queries

For a simple CRUD API like this, the service layer might seem thin, but it's good practice to keep it. It provides a place for any future business logic (e.g., sending an email notification when a task is completed).

First, create the database query file server/src/db/task.queries.js:

const db = require('./index');

const findAllByUserId = async (userId) => {
  const { rows } = await db.query('SELECT * FROM tasks WHERE user_id = $1 ORDER BY created_at DESC', [userId]);
  return rows;
};

const findByIdAndUserId = async (taskId, userId) => {
  const { rows } = await db.query('SELECT * FROM tasks WHERE id = $1 AND user_id = $2', [taskId, userId]);
  return rows[0];
};

const create = async (title, description, userId) => {
  const { rows } = await db.query(
    'INSERT INTO tasks (title, description, user_id) VALUES ($1, $2, $3) RETURNING *',
    [title, description, userId]
  );
  return rows[0];
};

const update = async (taskId, userId, updates) => {
    const { title, description, is_completed } = updates;
    const { rows } = await db.query(
        'UPDATE tasks SET title = $1, description = $2, is_completed = $3 WHERE id = $4 AND user_id = $5 RETURNING *',
        [title, description, is_completed, taskId, userId]
    );
    return rows[0];
};

const remove = async (taskId, userId) => {
    const { rowCount } = await db.query('DELETE FROM tasks WHERE id = $1 AND user_id = $2', [taskId, userId]);
    return rowCount;
};


module.exports = {
  findAllByUserId,
  findByIdAndUserId,
  create,
  update,
  remove
};

Note: The update query is simplified. A more robust implementation would dynamically build the SET clause based on which fields are provided in the updates object.

Next, create the service file server/src/services/task.service.js:

const taskQueries = require('../db/task.queries');

const getAllTasksForUser = async (userId) => {
  return await taskQueries.findAllByUserId(userId);
};

const createTaskForUser = async (title, description, userId) => {
  if (!title) throw new Error('Title is required.');
  return await taskQueries.create(title, description, userId);
};

const getTaskByIdForUser = async (taskId, userId) => {
  const task = await taskQueries.findByIdAndUserId(taskId, userId);
  if (!task) throw new Error('Task not found or you do not have permission to view it.');
  return task;
};

const updateUserTask = async (taskId, userId, updates) => {
    // First, get the existing task to ensure it exists and belongs to the user
    const existingTask = await getTaskByIdForUser(taskId, userId);

    // Merge existing data with updates
    const taskToUpdate = {
        title: updates.title !== undefined ? updates.title : existingTask.title,
        description: updates.description !== undefined ? updates.description : existingTask.description,
        is_completed: updates.is_completed !== undefined ? updates.is_completed : existingTask.is_completed,
    };

    const updatedTask = await taskQueries.update(taskId, userId, taskToUpdate);
    if (!updatedTask) throw new Error('Task not found or update failed.');
    return updatedTask;
};

const deleteUserTask = async (taskId, userId) => {
    const rowCount = await taskQueries.remove(taskId, userId);
    if (rowCount === 0) {
        throw new Error('Task not found or you do not have permission to delete it.');
    }
};

module.exports = {
  getAllTasksForUser,
  createTaskForUser,
  getTaskByIdForUser,
  updateUserTask,
  deleteUserTask,
};

Part 4: Assembling and Testing the API

Finally, we need to update our main server/src/index.js file to use our new routers and middleware.

// server/src/index.js

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');

// Import routers
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');

const app = express();

// Middleware
app.use(cors({
  origin: 'http://localhost:5173', // Your React app's address
  credentials: true, // Allow cookies to be sent
}));
app.use(express.json());
app.use(cookieParser()); // Parse cookies

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

// Test route
app.get('/', (req, res) => {
  res.send('Task Manager API is running...');
});

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

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

Make sure you have started your server with npm run dev.

Now, it's time to test your API using a tool like Postman or the Thunder Client extension in VS Code.

  1. Register a user: POST http://localhost:5000/api/auth/register with a JSON body: { "username": "testuser", "password": "password123" }.
  2. Log in: POST http://localhost:5000/api/auth/login with the same body. You should see a token cookie set in the response headers.
  3. Create a task: POST http://localhost:5000/api/tasks with a body like { "title": "Build the API" }. This will only work if the login cookie is sent with the request (most API clients do this automatically).
  4. Get all tasks: GET http://localhost:5000/api/tasks. You should see the task you just created.
  5. Try to get tasks without auth: Try the same GET request but manually remove the cookie from the request headers. You should get a 401 Unauthorized error, proving your middleware works!

The testing process is demonstrated clearly in the "PERN Stack Authentication Tutorial" video.

PERN Stack Authentication Tutorial | PostgreSQL, Express.js, React, Node.js

To see how to test these endpoints in Postman, including how it handles cookies automatically, watch this section of the video.

Watch from 33:51 to 37:33. This will guide you through testing the registration and login endpoints, which is a similar process to testing our task endpoints.


Conclusion

Fantastic work! You have just built the complete backend for our Task Manager application. You've created a robust, secure, and scalable REST API using a professional, layered architecture.

Key Takeaways:

  • You have implemented a full user authentication flow, including registration with password hashing and login with JWTs stored in secure cookies.
  • You have built a protected middleware that secures endpoints and provides user context to your route handlers.
  • You have implemented all CRUD operations for tasks, ensuring users can only access their own data.
  • Most importantly, you have successfully organized all this complex logic into a clean Router-Controller-Service-DB architecture, which is a key skill for any backend developer.

Preview of the Next Lesson:
Our backend is ready and waiting. In the next lesson, we will shift our focus to the frontend. We will start building the user interface with React and set up client-side navigation using React Router. Our first goal will be to connect our new React application to the login and registration endpoints we just built.