Build a Chat App Auth Backend

Build a JWT authentication API with PostgreSQL for a real-time chat app.

Introduction

30 Second Summary

Every time you log into a messaging app, an authentication system is silently verifying your identity and keeping your conversations private. This invisible security layer is what separates a toy demo from a production application.

In this project, you will build a complete authentication backend for a Messenger-style chat application using Node.js, Express, PostgreSQL, and JSON Web Tokens.

What You'll Build

A production-grade Express API with JWT access and refresh token authentication, backed by a PostgreSQL database designed for real-time chat.

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

  • An Express API with register, login, logout, and token refresh endpoints secured by httpOnly cookies.
  • A PostgreSQL database with 5 tables (users, conversations, participants, messages, message_reads) ready for real-time messaging.
  • Security middleware including bcrypt password hashing, rate limiting on auth routes, and CORS configuration for frontend integration.
  • Secret Mission: Implement refresh token rotation to detect stolen tokens.

Are there any prerequisites?

This project assumes basic Node.js and Express experience. You should also have PostgreSQL installed locally on your machine.

Before We Start

Before We Start...

Set Up the Project

Every chat app needs a backend that handles who can send messages and who can read them. Authentication is the gatekeeper that makes this possible.

In this step, you will initialize a Node.js project, install all the dependencies, create a PostgreSQL database, and wire up an Express server with middleware.

In this step, get ready to:
  • Initialize the project and install all production and dev dependencies.
  • Create a PostgreSQL database and connect to it from Node.js.
  • Configure the Express middleware stack and verify with a health endpoint.
Initialize the project and install dependencies

The project needs a dedicated folder, a package.json manifest, and several npm packages that handle authentication, database access, and security.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir chat-app-backend
cd chat-app-backend
  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The -y flag accepts all default values and immediately creates a package.json file. This file tracks your project's metadata and dependencies.

  • Install the production dependencies by running this command:
npm install express@5.2.1 pg@8.21.0 jsonwebtoken@9.0.3 bcryptjs@3.0.3 cookie-parser@1.4.7 cors@2.8.6 express-rate-limit@8.5.2 dotenv@16.5.0

What are all these packages?

  • express is the web framework that handles HTTP requests and routing.
  • pg is the PostgreSQL driver for Node.js, giving you connection pooling and query execution.
  • jsonwebtoken creates and verifies JWT tokens for authentication.
  • bcryptjs hashes passwords securely before storing them.
  • cookie-parser, cors, and express-rate-limit handle cookie reading, cross-origin requests, and brute-force protection.
  • dotenv loads environment variables from a .env file so secrets stay out of your code.
  • Install the dev dependency by running:
npm install --save-dev nodemon@3.1.9

What is nodemon?

Nodemon watches your files for changes and automatically restarts the server. This saves you from manually stopping and starting the server every time you edit code.

  • Confirm the installs succeeded. You should see a node_modules folder and a package-lock.json file by running:
ls

You should see node_modules, package-lock.json, and package.json in the output.

Seeing network or permission errors?

  • Check your internet connection. npm needs to download packages from the registry.
  • On macOS, if you see EACCES permission errors, avoid using sudo. Instead, fix npm permissions by following the npm docs.
  • Make sure you are inside the chat-app-backend folder when you run the install commands.

Help me troubleshoot my npm install errors.

  • Create the project folder structure by running:
mkdir -p src/config src/middleware src/routes src/controllers
  • Confirm the folders were created by running:
ls src

You should see config, controllers, middleware, and routes listed.

  • Open the chat-app-backend folder in VS Code. In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the chat-app-backend folder, then click Open.

You should see the folder structure in the VS Code Explorer sidebar on the left.

  • Open package.json by clicking on it in the Explorer sidebar.
  • Replace the entire contents of package.json with the following:
{
  "name": "chat-app-backend",
  "version": "1.0.0",
  "description": "Authentication backend for real-time chat application",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "bcryptjs": "3.0.3",
    "cookie-parser": "1.4.7",
    "cors": "2.8.6",
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "express-rate-limit": "8.5.2",
    "jsonwebtoken": "9.0.3",
    "pg": "8.21.0"
  },
  "devDependencies": {
    "nodemon": "3.1.9"
  }
}

What does this configure?

  • "main": "src/server.js" tells Node.js where your app's entry point lives.
  • "start" runs the server in production mode. "dev" runs it with nodemon for auto-restart during development.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Create the .env file. Right-click in the Explorer sidebar and select New File.
  • Name it .env and press Enter.
  • Paste the following configuration into .env:
PORT=5000
CLIENT_URL=http://localhost:3000

DB_USER=postgres
DB_HOST=localhost
DB_NAME=chat_app
DB_PASSWORD=your-postgres-password-here
DB_PORT=5432

ACCESS_TOKEN_SECRET=your-access-token-secret-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-here

NODE_ENV=development

What do these variables do?

  • DB_PASSWORD is the password you set when you installed PostgreSQL. Replace the placeholder with your actual password.
  • ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET are random strings used to sign your JWT tokens. Replace each placeholder with any long random string (at least 32 characters).
  • CLIENT_URL is where the React frontend will eventually run. CORS uses this to allow requests from your frontend.
  • Replace your-postgres-password-here with your actual PostgreSQL password.
  • Replace your-access-token-secret-here and your-refresh-token-secret-here with two different random strings (any mix of letters and numbers, at least 32 characters each).
  • Save .env.

Generate random secrets quickly

You can generate a random string by running node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" in your terminal. Run it twice to get two different secrets.

Not sure what your PostgreSQL password is?

This is the password you chose when you first installed PostgreSQL. If you never set one, try postgres as the default. If that does not work, you may need to reset your PostgreSQL password.

Help me find or reset my PostgreSQL password.

Set up PostgreSQL database and connection pool

Your app needs a database to store users, conversations, and messages. You will create the database now and configure a connection pool that efficiently manages database connections.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Connect to PostgreSQL by running:
psql -U postgres

You will be prompted for your PostgreSQL password. Enter it and press Enter.

  • Create the chat_app database by running:
CREATE DATABASE chat_app;

You should see CREATE DATABASE in the output confirming the database was created.

  • Exit psql by running:
\q

Cannot connect to PostgreSQL?

  • Make sure the PostgreSQL service is running. On macOS, check if it appears in your menu bar or run it from your Applications folder. On Windows, search for Services and ensure PostgreSQL is started.
  • If you see a "role does not exist" error, try connecting without the -U postgres flag, or use the username you set during installation.

Help me connect to my PostgreSQL database.

  • Create the database connection file. In the Explorer sidebar, right-click the src/config folder and select New File.
  • Name it db.js and press Enter.
  • Paste the following code into src/config/db.js:
const { Pool } = require('pg');

// Create a connection pool using environment variables
const pool = new Pool({
  user: process.env.DB_USER || 'postgres',
  host: process.env.DB_HOST || 'localhost',
  database: process.env.DB_NAME || 'chat_app',
  password: process.env.DB_PASSWORD || 'postgres',
  port: process.env.DB_PORT || 5432
});

module.exports = { pool };

What is a connection pool?

A connection pool keeps a set of database connections open and reuses them across requests. Opening a new connection for every query is slow. The pool handles this efficiently, giving each request a connection from the pool and returning it when done.

The || 'postgres' fallbacks are defaults in case an environment variable is missing. Your .env values take priority.

  • Save src/config/db.js.
Configure Express middleware stack

Middleware functions run on every incoming request before your route handlers see it. You will configure CORS, cookie parsing, and JSON body parsing, then verify the server connects to your database.

  • Create a placeholder auth routes file. Right-click the src/routes folder in the Explorer sidebar and select New File.
  • Name it auth.js and press Enter.
  • Paste this placeholder code into src/routes/auth.js:
const express = require('express');
const router = express.Router();

// Routes will be added in the next step

module.exports = router;

Why create a placeholder?

The server file imports this route module on startup. Without this placeholder, Node.js would throw a "module not found" error. You will fill in the actual routes in a later step.

  • Save src/routes/auth.js.
  • Create the main server file. Right-click the src folder in the Explorer sidebar and select New File.
  • Name it server.js and press Enter.
  • Paste the following code into src/server.js:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const { pool } = require('./config/db');

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

// Configure CORS to allow requests from the frontend
app.use(cors({
  origin: process.env.CLIENT_URL || 'http://localhost:3000',
  credentials: true
}));
// Parse JSON request bodies
app.use(express.json());
// Parse cookies from incoming requests
app.use(cookieParser());

// Health check endpoint to verify the server is running
app.get('/api/health', (req, res) => {
  res.json({ success: true, message: 'Server is running' });
});

// Mount auth routes at /api/auth
app.use('/api/auth', authRoutes);

// Start the server and test database connection
app.listen(PORT, async () => {
  try {
    const client = await pool.connect();
    console.log('Connected to PostgreSQL');
    client.release();
  } catch (err) {
    console.error('Database connection failed:', err.message);
  }
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • require('dotenv').config() loads your .env variables into process.env so the rest of the file can access them.
  • cors({ credentials: true }) allows the frontend to send cookies with every request. Without this, the browser blocks cross-origin cookie transmission.
  • cookieParser() reads cookies from the request headers and makes them available on req.cookies.
  • The app.listen callback tests the database connection by borrowing and immediately releasing a client from the pool.
  • Save src/server.js.
  • Start the development server by running this in your VS Code terminal:
npm run dev

You should see two lines in the terminal output:

Connected to PostgreSQL
Server running on port 5000

Seeing 'Database connection failed'?

  • Double-check that DB_PASSWORD in your .env file matches the password you used to connect in psql.
  • Make sure the PostgreSQL service is still running. The psql connection you tested earlier confirms this, but the service may have stopped.
  • Confirm DB_NAME is set to chat_app (with an underscore, not a hyphen).

Help me fix my PostgreSQL connection error.

  • Test the health endpoint. Open your browser and navigate to http://localhost:5000/api/health.

You should see a JSON response:

{ "success": true, "message": "Server is running" }

Browser shows 'This site can't be reached'?

  • Make sure your terminal still shows the server running. If you see an error, fix it and let nodemon restart.
  • Confirm the URL is exactly http://localhost:5000/api/health with no typos.
  • Check that no other process is using port 5000. Stop other dev servers if needed.

Help me debug why my Express server is not responding.

✔️ 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

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

{
  "name": "chat-app-backend",
  "version": "1.0.0",
  "description": "Authentication backend for real-time chat application",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "bcryptjs": "3.0.3",
    "cookie-parser": "1.4.7",
    "cors": "2.8.6",
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "express-rate-limit": "8.5.2",
    "jsonwebtoken": "9.0.3",
    "pg": "8.21.0"
  },
  "devDependencies": {
    "nodemon": "3.1.9"
  }
}
PORT=5000
CLIENT_URL=http://localhost:3000

DB_USER=postgres
DB_HOST=localhost
DB_NAME=chat_app
DB_PASSWORD=your-postgres-password-here
DB_PORT=5432

ACCESS_TOKEN_SECRET=your-access-token-secret-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-here

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

const pool = new Pool({
  user: process.env.DB_USER || 'postgres',
  host: process.env.DB_HOST || 'localhost',
  database: process.env.DB_NAME || 'chat_app',
  password: process.env.DB_PASSWORD || 'postgres',
  port: process.env.DB_PORT || 5432
});

module.exports = { pool };
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/auth');
const { pool } = require('./config/db');

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

app.use(cors({
  origin: process.env.CLIENT_URL || 'http://localhost:3000',
  credentials: true
}));
app.use(express.json());
app.use(cookieParser());

app.get('/api/health', (req, res) => {
  res.json({ success: true, message: 'Server is running' });
});

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

app.listen(PORT, async () => {
  try {
    const client = await pool.connect();
    console.log('Connected to PostgreSQL');
    client.release();
  } catch (err) {
    console.error('Database connection failed:', err.message);
  }
  console.log(`Server running on port ${PORT}`);
});

Your Express server is running, connected to PostgreSQL, and responding to requests. Next up, you will design the database schema that stores users, conversations, and messages.

Create the Database Schema

Your server is running and connected to an empty PostgreSQL database. Before you can store users, conversations, or messages, you need tables that define how that data is structured.

In this step, you will write the full database schema for a chat application. This means defining six tables with proper relationships between them, plus performance indexes that keep queries fast as the data grows.

In this step, get ready to:
  • Create the users and refresh_tokens tables for authentication.
  • Create the conversations, participants, messages, and message_reads tables for chat functionality.
  • Add database indexes for query performance.
Create the users table and UUID extension

Every row in your database needs a unique identifier. Instead of sequential integers (1, 2, 3), this schema uses UUIDs. UUIDs are random 128-bit values that are globally unique without needing a central counter.

Why UUIDs instead of auto-incrementing IDs?

Sequential IDs expose how many users or messages your app has. They also create conflicts when merging data from multiple servers.

UUIDs solve both problems. They reveal nothing about your data size, and they can be generated independently on any server without collisions.

  • Create a new file called schema.sql inside the src/config/ folder.
  • Add the UUID extension and the first two tables by pasting this code:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  email VARCHAR(255) UNIQUE NOT NULL,
  username VARCHAR(30) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  avatar_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE refresh_tokens (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash VARCHAR(255) NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

What does this code do?

  • CREATE EXTENSION IF NOT EXISTS "uuid-ossp" loads the PostgreSQL extension that provides the uuid_generate_v4() function.
  • The users table stores account credentials. password_hash holds the bcrypt-hashed password, never the plaintext.
  • The refresh_tokens table tracks issued refresh tokens. ON DELETE CASCADE means if a user is deleted, their tokens are automatically removed.
  • TIMESTAMPTZ stores timestamps with timezone awareness, which prevents bugs when your server and clients are in different timezones.

You will not run this file yet. The next chunk completes the schema, then you will execute the entire file at once.

Create the chat schema tables

A chat app needs more than user accounts. It needs a way to group users into conversations, store the messages they send, and track which messages have been read.

  • In src/config/schema.sql, add the following code below the refresh_tokens table:
CREATE TABLE conversations (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title VARCHAR(100),
  is_group BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE participants (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  joined_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(conversation_id, user_id)
);

CREATE TABLE messages (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  message_type VARCHAR(20) DEFAULT 'text',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  edited_at TIMESTAMPTZ
);

CREATE TABLE message_reads (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  read_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(message_id, user_id)
);

CREATE INDEX idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
CREATE INDEX idx_participants_user ON participants(user_id);
CREATE INDEX idx_participants_conversation ON participants(conversation_id);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_message_reads_message ON message_reads(message_id);

What does this code do?

  • conversations holds each chat thread. The is_group flag distinguishes one-on-one DMs from group chats.
  • participants links users to conversations with a UNIQUE constraint preventing the same user from being added twice.
  • messages stores every message. edited_at is nullable because most messages are never edited.
  • message_reads tracks read receipts. One row per user per message.
  • The idx_messages_conversation_created index sorts messages by newest first within a conversation. This makes loading chat history fast without scanning every row.
  • Save src/config/schema.sql.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before running it.

ⓧ I'd like to double check the full code

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  email VARCHAR(255) UNIQUE NOT NULL,
  username VARCHAR(30) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  avatar_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE refresh_tokens (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash VARCHAR(255) NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE conversations (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title VARCHAR(100),
  is_group BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE participants (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  joined_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(conversation_id, user_id)
);

CREATE TABLE messages (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  message_type VARCHAR(20) DEFAULT 'text',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  edited_at TIMESTAMPTZ
);

CREATE TABLE message_reads (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  read_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(message_id, user_id)
);

CREATE INDEX idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
CREATE INDEX idx_participants_user ON participants(user_id);
CREATE INDEX idx_participants_conversation ON participants(conversation_id);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_message_reads_message ON message_reads(message_id);
  • Run the schema file against your chat_app database by executing this command:
psql -U postgres -d chat_app -f src/config/schema.sql

What does this command do?

  • -U postgres connects as the postgres user.
  • -d chat_app targets the chat_app database you created in Step 1.
  • -f src/config/schema.sql executes every SQL statement in the file from top to bottom.

You should see output confirming each CREATE statement succeeded.

  • Verify all six tables exist by running:
psql -U postgres -d chat_app -c "\dt"

You should see a table listing conversations, message_reads, messages, participants, refresh_tokens, and users in the output.

Seeing a "No such file or directory" error?

  • Make sure your terminal is in the project root folder (the one containing src/). The -f flag uses a relative path, so it must be run from the correct location.
  • If you see a password prompt, enter the PostgreSQL password you set during installation.
  • If you see "relation already exists", it means the tables were already created from a previous run. You can drop and recreate the database with DROP DATABASE chat_app; CREATE DATABASE chat_app; in psql, then rerun the schema file.

Help me troubleshoot my schema.sql execution error.

Your database schema is ready. All six tables are in place, linked by foreign keys, and optimized with indexes. Next up, you will build the authentication system that writes to these tables.

Build the Authentication System

Your database is ready with all the tables it needs. Now it's time to build the layer that controls who can access the chat app.

Authentication is the gatekeeper of every API. In this step, you'll create a complete JWT-based auth system that issues short-lived access tokens and long-lived refresh tokens, stores them in httpOnly cookies, and protects routes from unauthorized access.

In this step, get ready to:
  • Create the auth controller with register and login handlers.
  • Create auth middleware and wire up protected routes.
  • Implement the refresh token flow for persistent sessions.
Create the auth controller with register and login

The auth controller is the brain of the authentication system. It handles user registration, password hashing, and token generation.

Why httpOnly cookies over localStorage?

Tokens stored in localStorage are accessible to any JavaScript running on the page, making them vulnerable to XSS attacks. httpOnly cookies are invisible to client-side JavaScript. The browser attaches them to requests automatically, but malicious scripts can never read them.

This is a common interview question. The answer is always httpOnly cookies for production authentication.

Before the controller can validate input, it needs helper functions. Create the validation middleware first.

  • Create a new file at src/middleware/validate.js and add the following validation helpers:
exports.validateRegistration = (email, username, password) => {
  const errors = [];

  // Check email format with a regex pattern
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('Valid email is required');
  }
  // Username must be 3-30 characters, letters/numbers/underscore only
  if (!username || username.length < 3 || username.length > 30 || !/^[a-zA-Z0-9_]+$/.test(username)) {
    errors.push('Username must be 3-30 alphanumeric characters');
  }
  // Enforce minimum password length
  if (!password || password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  return errors;
};

exports.validateLogin = (email, password) => {
  const errors = [];

  if (!email) {
    errors.push('Email is required');
  }
  if (!password) {
    errors.push('Password is required');
  }

  return errors;
};

What does this code do?

Each function returns an array of error messages. An empty array means the input is valid.

  • validateRegistration checks email format with a regex, enforces username length (3-30 chars, alphanumeric), and requires passwords of at least 8 characters.
  • validateLogin confirms both email and password are present before hitting the database.
  • Save src/middleware/validate.js.

Now create the auth controller with the token helper functions.

  • Create a new file at src/controllers/authController.js and add the imports and helper functions:
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool } = require('../config/db');
const { validateRegistration, validateLogin } = require('../middleware/validate');

// Generate a short-lived access token (15 minutes)
const generateAccessToken = (user) => {
  return jwt.sign(
    { id: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
};

// Generate a long-lived refresh token (7 days)
const generateRefreshToken = (user) => {
  return jwt.sign(
    { id: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
};

// Set both tokens as httpOnly cookies on the response
const setTokenCookies = (res, accessToken, refreshToken) => {
  res.cookie('accessToken', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000
  });
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });
};

What does this code do?

  • generateAccessToken creates a JWT that expires in 15 minutes. It includes the user's ID and email in the payload.
  • generateRefreshToken creates a JWT that expires in 7 days. It only includes the user ID since it's used solely for re-issuing access tokens.
  • setTokenCookies attaches both tokens to the response as httpOnly cookies. The secure flag ensures cookies are only sent over HTTPS in production. The sameSite: 'strict' flag prevents cross-site request forgery.

Next, add the register function to the same file.

  • Below the setTokenCookies function in src/controllers/authController.js, add the register handler:
exports.register = async (req, res) => {
  try {
    const { email, username, password } = req.body;

    // Validate the input fields
    const errors = validateRegistration(email, username, password);
    if (errors.length > 0) {
      return res.status(400).json({ success: false, error: { message: errors[0], code: 'VALIDATION_ERROR' } });
    }

    // Check if email or username is already taken
    const existingUser = await pool.query(
      'SELECT id FROM users WHERE email = $1 OR username = $2',
      [email, username]
    );
    if (existingUser.rows.length > 0) {
      return res.status(409).json({ success: false, error: { message: 'Email or username already exists', code: 'DUPLICATE_USER' } });
    }

    // Hash the password with bcrypt (salt rounds: 10)
    const salt = await bcrypt.genSalt(10);
    const passwordHash = await bcrypt.hash(password, salt);

    // Insert the new user into the database
    const result = await pool.query(
      'INSERT INTO users (email, username, password_hash) VALUES ($1, $2, $3) RETURNING id, email, username, created_at',
      [email, username, passwordHash]
    );
    const user = result.rows[0];

    // Generate tokens and store refresh token hash in database
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await pool.query(
      'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
      [user.id, refreshTokenHash]
    );

    // Set cookies and respond with user data
    setTokenCookies(res, accessToken, refreshToken);

    res.status(201).json({
      success: true,
      data: { user: { id: user.id, email: user.email, username: user.username, created_at: user.created_at } }
    });
  } catch (err) {
    console.error('Register error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

What does this code do?

The register handler follows a secure pattern for creating new users:

  • It validates the input, then checks the database for duplicates before doing any expensive work.
  • bcrypt.genSalt(10) generates a random salt, and bcrypt.hash() combines it with the password. The plain password is never stored.
  • The refresh token itself is also hashed before storing. If the database is compromised, attackers cannot reuse the tokens.
  • Parameterized queries ($1, $2) prevent SQL injection.

Now add the login function that authenticates existing users.

  • Below the exports.register function in the same file, add the login handler:
exports.login = async (req, res) => {
  try {
    const { email, password } = req.body;

    // Validate that email and password are provided
    const errors = validateLogin(email, password);
    if (errors.length > 0) {
      return res.status(400).json({ success: false, error: { message: errors[0], code: 'VALIDATION_ERROR' } });
    }

    // Look up the user by email
    const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
    if (result.rows.length === 0) {
      return res.status(401).json({ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } });
    }

    // Compare the provided password against the stored hash
    const user = result.rows[0];
    const isMatch = await bcrypt.compare(password, user.password_hash);
    if (!isMatch) {
      return res.status(401).json({ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } });
    }

    // Generate fresh tokens
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    // Store the hashed refresh token
    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await pool.query(
      'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
      [user.id, refreshTokenHash]
    );

    // Set cookies and respond
    setTokenCookies(res, accessToken, refreshToken);

    res.json({
      success: true,
      data: { user: { id: user.id, email: user.email, username: user.username, created_at: user.created_at } }
    });
  } catch (err) {
    console.error('Login error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

What does this code do?

  • The login handler returns the same generic error message for both "user not found" and "wrong password." This prevents attackers from discovering which emails are registered.
  • bcrypt.compare() checks the provided password against the stored hash without ever decrypting the original.
  • A new refresh token is generated and stored on every login, supporting multiple device sessions.
  • Save src/controllers/authController.js.

Seeing a module not found error?

If your terminal shows an error about ../middleware/validate not being found, make sure you saved src/middleware/validate.js in the correct location. The file must be inside src/middleware/, not src/controllers/.

help me fix a module not found error for validate.js

Create the auth middleware and protected routes

The auth middleware sits between incoming requests and your route handlers. It verifies the access token and blocks unauthorized access.

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

exports.authenticate = (req, res, next) => {
  // Read the access token from the httpOnly cookie
  const token = req.cookies.accessToken;

  if (!token) {
    return res.status(401).json({ success: false, error: { message: 'Access token required', code: 'NO_TOKEN' } });
  }

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

What does this code do?

  • The middleware reads the access token from req.cookies.accessToken (set by cookie-parser which is already configured in server.js).
  • jwt.verify() checks the token's signature and expiry. If valid, the decoded payload (containing id and email) is attached to req.user.
  • Calling next() passes control to the actual route handler. If verification fails, the request is rejected with a 401 before reaching the handler.
  • Save src/middleware/auth.js.

Now add the logout and getMe handlers to the auth controller.

  • Return to src/controllers/authController.js and add the logout handler below exports.login:
exports.logout = async (req, res) => {
  try {
    // Remove all refresh tokens for this user
    await pool.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.id]);

    // Clear the token cookies
    res.clearCookie('accessToken');
    res.clearCookie('refreshToken');

    res.json({ success: true, message: 'Logged out successfully' });
  } catch (err) {
    console.error('Logout error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

exports.getMe = async (req, res) => {
  try {
    // Fetch user profile using the ID from the verified token
    const result = await pool.query(
      'SELECT id, email, username, avatar_url, created_at FROM users WHERE id = $1',
      [req.user.id]
    );
    if (result.rows.length === 0) {
      return res.status(404).json({ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
    }
    res.json({ success: true, data: { user: result.rows[0] } });
  } catch (err) {
    console.error('GetMe error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

What does this code do?

  • logout deletes all refresh tokens for the user from the database (invalidating all sessions) and clears the cookie jar.
  • getMe is a protected route. Because the auth middleware already verified the token and attached req.user, this handler simply queries the database for the full profile. It never returns the password_hash field.
  • Save src/controllers/authController.js.

Finally, create the routes file that ties everything together.

  • Create a new file at src/routes/auth.js and add the route definitions:
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const authController = require('../controllers/authController');
const { authenticate } = require('../middleware/auth');

// Rate limiter for auth routes: 5 attempts per 15 minutes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { success: false, error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } }
});

// Public routes (rate-limited)
router.post('/register', authLimiter, authController.register);
router.post('/login', authLimiter, authController.login);

// Token refresh (no auth required, uses refresh token cookie)
router.post('/refresh', authController.refresh);

// Protected routes (require valid access token)
router.post('/logout', authenticate, authController.logout);
router.get('/me', authenticate, authController.getMe);

module.exports = router;

What does this code do?

  • authLimiter restricts login and register to 5 attempts per 15-minute window. This prevents brute-force password guessing.
  • The authenticate middleware is applied to /logout and /me only. These routes need a valid access token.
  • /refresh does not use the authenticate middleware because it's called when the access token has expired. It reads the refresh token cookie instead.
  • Save src/routes/auth.js.
  • Check your terminal. Nodemon should restart the server and display Connected to PostgreSQL and Server running on port 5000.

Now test the register and getMe flow to confirm everything works.

  • Open a new terminal and register a test user by running:
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","username":"testuser","password":"password123"}' \
  -c cookies.txt
  • You should see a JSON response with "success": true and your user data (id, email, username, created_at).
  • Now hit the protected /me endpoint using the saved cookies:
curl http://localhost:5000/api/auth/me -b cookies.txt
  • You should see your user profile returned with "success": true.

Using Postman instead of curl

If you prefer Postman, create a POST request to http://localhost:5000/api/auth/register with a raw JSON body. Postman automatically saves httpOnly cookies from responses and sends them back on subsequent requests to the same domain.

Getting a 401 on /me?

Make sure you used -c cookies.txt on the register request (to save cookies) and -b cookies.txt on the me request (to send cookies). Without the cookie file, curl does not persist cookies between requests.

help me fix a 401 unauthorized error when hitting /api/auth/me

Implement the refresh token flow

Access tokens expire after 15 minutes. Without a refresh flow, users would have to log in again every 15 minutes. The refresh endpoint issues a new access token using the longer-lived refresh token.

  • Return to src/controllers/authController.js and add the refresh handler between exports.logout and exports.getMe:
exports.refresh = async (req, res) => {
  try {
    // Read the refresh token from the cookie
    const refreshToken = req.cookies.refreshToken;
    if (!refreshToken) {
      return res.status(401).json({ success: false, error: { message: 'No refresh token provided', code: 'NO_TOKEN' } });
    }

    // Verify the refresh token signature and expiry
    let decoded;
    try {
      decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    } catch (err) {
      return res.status(401).json({ success: false, error: { message: 'Invalid refresh token', code: 'INVALID_TOKEN' } });
    }

    // Find valid (non-expired) refresh tokens for this user
    const storedTokens = await pool.query(
      'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
      [decoded.id]
    );

    // Compare the provided token against stored hashes
    let validToken = null;
    for (const stored of storedTokens.rows) {
      const isMatch = await bcrypt.compare(refreshToken, stored.token_hash);
      if (isMatch) {
        validToken = stored;
        break;
      }
    }

    if (!validToken) {
      return res.status(401).json({ success: false, error: { message: 'Refresh token not recognized', code: 'TOKEN_NOT_FOUND' } });
    }

    // Look up the user and issue a new access token
    const user = await pool.query('SELECT id, email, username FROM users WHERE id = $1', [decoded.id]);
    if (user.rows.length === 0) {
      return res.status(401).json({ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
    }

    const newAccessToken = generateAccessToken(user.rows[0]);

    // Set the new access token cookie
    res.cookie('accessToken', newAccessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000
    });

    res.json({ success: true, message: 'Token refreshed' });
  } catch (err) {
    console.error('Refresh error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

What does this code do?

The refresh flow has multiple layers of validation:

  • First it verifies the JWT signature and checks that the token has not expired.
  • Then it queries the database for stored token hashes belonging to that user and uses bcrypt.compare() to match the incoming token against each hash. This confirms the token was actually issued by our server and has not been revoked.
  • Only after both checks pass does it issue a new access token. The refresh token itself stays the same until it expires or the user logs out.
  • Save src/controllers/authController.js.
  • Test the refresh endpoint by running:
curl -X POST http://localhost:5000/api/auth/refresh -b cookies.txt -c cookies.txt
  • You should see {"success":true,"message":"Token refreshed"} in the response.
  • Confirm the new access token works by hitting /me again:
curl http://localhost:5000/api/auth/me -b cookies.txt
  • You should see your user profile returned successfully.

Refresh returning 401?

Make sure the curl command uses both -b cookies.txt (send existing cookies) and -c cookies.txt (save new cookies from the response). Also confirm your REFRESH_TOKEN_SECRET in .env is not empty.

help me debug why my refresh token endpoint returns 401

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved and your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created in this step.

const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool } = require('../config/db');
const { validateRegistration, validateLogin } = require('../middleware/validate');

const generateAccessToken = (user) => {
  return jwt.sign(
    { id: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );
};

const generateRefreshToken = (user) => {
  return jwt.sign(
    { id: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
};

const setTokenCookies = (res, accessToken, refreshToken) => {
  res.cookie('accessToken', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000
  });
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });
};

exports.register = async (req, res) => {
  try {
    const { email, username, password } = req.body;

    const errors = validateRegistration(email, username, password);
    if (errors.length > 0) {
      return res.status(400).json({ success: false, error: { message: errors[0], code: 'VALIDATION_ERROR' } });
    }

    const existingUser = await pool.query(
      'SELECT id FROM users WHERE email = $1 OR username = $2',
      [email, username]
    );
    if (existingUser.rows.length > 0) {
      return res.status(409).json({ success: false, error: { message: 'Email or username already exists', code: 'DUPLICATE_USER' } });
    }

    const salt = await bcrypt.genSalt(10);
    const passwordHash = await bcrypt.hash(password, salt);

    const result = await pool.query(
      'INSERT INTO users (email, username, password_hash) VALUES ($1, $2, $3) RETURNING id, email, username, created_at',
      [email, username, passwordHash]
    );
    const user = result.rows[0];

    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await pool.query(
      'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
      [user.id, refreshTokenHash]
    );

    setTokenCookies(res, accessToken, refreshToken);

    res.status(201).json({
      success: true,
      data: { user: { id: user.id, email: user.email, username: user.username, created_at: user.created_at } }
    });
  } catch (err) {
    console.error('Register error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

exports.login = async (req, res) => {
  try {
    const { email, password } = req.body;

    const errors = validateLogin(email, password);
    if (errors.length > 0) {
      return res.status(400).json({ success: false, error: { message: errors[0], code: 'VALIDATION_ERROR' } });
    }

    const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
    if (result.rows.length === 0) {
      return res.status(401).json({ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } });
    }

    const user = result.rows[0];
    const isMatch = await bcrypt.compare(password, user.password_hash);
    if (!isMatch) {
      return res.status(401).json({ success: false, error: { message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' } });
    }

    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await pool.query(
      'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
      [user.id, refreshTokenHash]
    );

    setTokenCookies(res, accessToken, refreshToken);

    res.json({
      success: true,
      data: { user: { id: user.id, email: user.email, username: user.username, created_at: user.created_at } }
    });
  } catch (err) {
    console.error('Login error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

exports.logout = async (req, res) => {
  try {
    await pool.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.id]);

    res.clearCookie('accessToken');
    res.clearCookie('refreshToken');

    res.json({ success: true, message: 'Logged out successfully' });
  } catch (err) {
    console.error('Logout error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

exports.refresh = async (req, res) => {
  try {
    const refreshToken = req.cookies.refreshToken;
    if (!refreshToken) {
      return res.status(401).json({ success: false, error: { message: 'No refresh token provided', code: 'NO_TOKEN' } });
    }

    let decoded;
    try {
      decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    } catch (err) {
      return res.status(401).json({ success: false, error: { message: 'Invalid refresh token', code: 'INVALID_TOKEN' } });
    }

    const storedTokens = await pool.query(
      'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
      [decoded.id]
    );

    let validToken = null;
    for (const stored of storedTokens.rows) {
      const isMatch = await bcrypt.compare(refreshToken, stored.token_hash);
      if (isMatch) {
        validToken = stored;
        break;
      }
    }

    if (!validToken) {
      return res.status(401).json({ success: false, error: { message: 'Refresh token not recognized', code: 'TOKEN_NOT_FOUND' } });
    }

    const user = await pool.query('SELECT id, email, username FROM users WHERE id = $1', [decoded.id]);
    if (user.rows.length === 0) {
      return res.status(401).json({ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
    }

    const newAccessToken = generateAccessToken(user.rows[0]);

    res.cookie('accessToken', newAccessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000
    });

    res.json({ success: true, message: 'Token refreshed' });
  } catch (err) {
    console.error('Refresh error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};

exports.getMe = async (req, res) => {
  try {
    const result = await pool.query(
      'SELECT id, email, username, avatar_url, created_at FROM users WHERE id = $1',
      [req.user.id]
    );
    if (result.rows.length === 0) {
      return res.status(404).json({ success: false, error: { message: 'User not found', code: 'USER_NOT_FOUND' } });
    }
    res.json({ success: true, data: { user: result.rows[0] } });
  } catch (err) {
    console.error('GetMe error:', err);
    res.status(500).json({ success: false, error: { message: 'Internal server error', code: 'SERVER_ERROR' } });
  }
};
const jwt = require('jsonwebtoken');

exports.authenticate = (req, res, next) => {
  const token = req.cookies.accessToken;

  if (!token) {
    return res.status(401).json({ success: false, error: { message: 'Access token required', code: 'NO_TOKEN' } });
  }

  try {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ success: false, error: { message: 'Invalid or expired token', code: 'INVALID_TOKEN' } });
  }
};
exports.validateRegistration = (email, username, password) => {
  const errors = [];

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('Valid email is required');
  }
  if (!username || username.length < 3 || username.length > 30 || !/^[a-zA-Z0-9_]+$/.test(username)) {
    errors.push('Username must be 3-30 alphanumeric characters');
  }
  if (!password || password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  return errors;
};

exports.validateLogin = (email, password) => {
  const errors = [];

  if (!email) {
    errors.push('Email is required');
  }
  if (!password) {
    errors.push('Password is required');
  }

  return errors;
};
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');
const authController = require('../controllers/authController');
const { authenticate } = require('../middleware/auth');

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { success: false, error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } }
});

router.post('/register', authLimiter, authController.register);
router.post('/login', authLimiter, authController.login);
router.post('/logout', authenticate, authController.logout);
router.post('/refresh', authController.refresh);
router.get('/me', authenticate, authController.getMe);

module.exports = router;

Your authentication system is fully operational. Next up, you'll layer on security middleware to harden the API against common attacks.

Add Security Middleware

In the last step, you built the complete auth system with registration, login, token refresh, and protected routes. Your API handles the happy path well, but it still trusts whatever data users send.

Without input validation, an attacker could submit a malformed email, an empty password, or a script tag in a username field. In this step, you'll test the rate limiter that prevents brute-force attacks, then create validation middleware that rejects bad data before it reaches your database.

In this step, get ready to:
  • Test rate limiting on auth endpoints.
  • Create input validation and sanitization helpers.
  • Verify proper error responses for invalid data.
Add rate limiting to auth routes

Rate limiting prevents attackers from hammering your login endpoint with thousands of password guesses. The rate limiter is already configured in your src/routes/auth.js file from when you set up the auth routes.

  • In VS Code, open src/routes/auth.js and find the authLimiter configuration near the top:
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { success: false, error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } }
});

What does this code do?

This configures express-rate-limit to track requests by IP address.

  • windowMs: 15 * 60 * 1000 sets a 15-minute sliding window.
  • max: 5 allows only 5 requests per window before blocking.
  • message defines the JSON response when the limit is hit (HTTP 429).

This limiter is applied specifically to the /register and /login routes, so other endpoints like /refresh remain unaffected.

Let's confirm the rate limiter works by flooding the login endpoint with requests.

  • Test the rate limiter by running this command in your terminal 6 times:
curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@test.com","password":"wrongpass"}'
  • Press the up arrow key and hit Enter to repeat the command until you have run it 6 times total.

The first 5 responses return a 401 status with "Invalid credentials". The 6th response returns a 429 status with the "Too many attempts" message.

Still seeing 401 on the 6th attempt?

  • Make sure you're running the command rapidly without waiting. The 15-minute window resets after each period.
  • Confirm your server is running. If you see "Connection refused", restart with npm run dev in your project directory.
  • If you registered a user with test@test.com previously, the first responses might be 401 instead of 400. That's fine. The 6th should still be 429.

help me debug why I'm not getting a 429 response after 5 login attempts

Add input validation and XSS protection

Your auth controller already calls validateRegistration and validateLogin functions, but the file that defines them doesn't exist yet. Let's create it now.

  • Create a new file called validate.js inside the src/middleware/ folder.
  • Add the registration validator by pasting this code into src/middleware/validate.js:
exports.validateRegistration = (email, username, password) => {
  const errors = [];

  // Check email format with a regex pattern
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('Valid email is required');
  }
  // Username must be 3-30 characters, letters/numbers/underscores only
  if (!username || username.length < 3 || username.length > 30 || !/^[a-zA-Z0-9_]+$/.test(username)) {
    errors.push('Username must be 3-30 alphanumeric characters');
  }
  // Enforce minimum password length
  if (!password || password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  return errors;
};

What does this code do?

  • The regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ checks that the email has text before and after an @ symbol and includes a dot in the domain.
  • The username regex /^[a-zA-Z0-9_]+$/ ensures only alphanumeric characters and underscores are accepted.
  • Each failed check pushes an error string into the array. The controller reads the first error and returns it to the client.
  • Add the login validator below the validateRegistration function:
exports.validateLogin = (email, password) => {
  const errors = [];

  // Ensure email is provided
  if (!email) {
    errors.push('Email is required');
  }
  // Ensure password is provided
  if (!password) {
    errors.push('Password is required');
  }

  return errors;
};

What does this code do?

This performs basic presence checks for login. It does not validate email format here because the user already passed that check during registration. If either field is missing, the error response fires immediately without querying the database.

  • Add the HTML sanitizer at the bottom of the file:
exports.sanitizeHtml = (str) => {
  // Prevent stored XSS by escaping HTML special characters
  if (typeof str !== 'string') return str;
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
};

What does this code do?

This function escapes the five HTML special characters (&, <, >, ", ') into their safe entity equivalents.

When the chat messaging feature is added later, this prevents attackers from injecting <script> tags into messages that other users would render.

  • Save src/middleware/validate.js.
  • Check your terminal. If nodemon is running, the server should restart automatically without errors.

Seeing a syntax error on restart?

  • Make sure every regex pattern has matching forward slashes and no extra backslashes.
  • Check that exports.validateRegistration, exports.validateLogin, and exports.sanitizeHtml each end with a semicolon after the closing };.
  • Confirm the file is saved as src/middleware/validate.js (not validation.js or validators.js). The controller imports from ../middleware/validate.

help me fix a syntax error in my validate.js middleware file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

exports.validateRegistration = (email, username, password) => {
  const errors = [];

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('Valid email is required');
  }
  if (!username || username.length < 3 || username.length > 30 || !/^[a-zA-Z0-9_]+$/.test(username)) {
    errors.push('Username must be 3-30 alphanumeric characters');
  }
  if (!password || password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  return errors;
};

exports.validateLogin = (email, password) => {
  const errors = [];

  if (!email) {
    errors.push('Email is required');
  }
  if (!password) {
    errors.push('Password is required');
  }

  return errors;
};

exports.sanitizeHtml = (str) => {
  if (typeof str !== 'string') return str;
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
};

Now let's verify that validation rejects bad input with a proper error response.

  • Test registration validation by sending an invalid email and short password:
curl -X POST http://localhost:5000/api/auth/register -H "Content-Type: application/json" -d '{"email":"notanemail","username":"ab","password":"short"}'

You should see a 400 response with the JSON structure {"success":false,"error":{"message":"Valid email is required","code":"VALIDATION_ERROR"}}.

Why only one error message?

The controller returns errors[0] (only the first error) rather than all of them. This is a common API pattern that gives clients one clear thing to fix at a time, making form validation on the frontend simpler to display.

Getting a 500 error instead of 400?

  • Check that src/middleware/validate.js exports the function as validateRegistration (not validateRegister or another name).
  • Confirm the function returns an array. If it returns undefined, the errors.length check in the controller will throw.

help me debug why my validation returns 500 instead of a 400 error

Secret mission

Implement Refresh Token Rotation With Theft Detection

Your current refresh flow reuses the same token forever. An attacker who steals it has permanent access. Implement refresh token rotation so every refresh invalidates the old token, and add theft detection that locks out an attacker the moment they try to reuse a consumed token.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • PostgreSQL database chat_app with 6 tables (local, free).
  • Project folder chat-app-backend with all source code and node_modules (local, free).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend this project with real-time messaging next.

  • The PostgreSQL database persists locally with no ongoing cost.
  • Your Express server will stop when you close the terminal, but you can restart it anytime with npm run dev.
  • Your project files remain on your Desktop ready for the next session.

Pause - I'll come back to this later

Shut down the running server process to free up your terminal. Your database and files stay intact for when you return.

  • In the terminal where your server is running, press Ctrl+C to stop the Express server.
  • Your chat_app database stays on disk. PostgreSQL keeps it available whenever the service is running.
  • When you're ready to resume, open a terminal in your project folder and run npm run dev to start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • In the terminal where your server is running, press Ctrl+C to stop the Express server.
  • Drop the PostgreSQL database by running this in your terminal:
psql -U postgres -c "DROP DATABASE chat_app;"
  • Remove the project folder by running:
cd ~/Desktop
rm -rf chat-app-backend

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force chat-app-backend
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see chat-app-backend in the output.

Nice Work!

Nice Work!

You just built a production-grade authentication backend for a real-time chat application. This is the exact kind of system that interviewers probe when they ask about security, database design, and API architecture.

You've learned how to:

  • Build a complete Express authentication API with JWT access and refresh tokens stored in httpOnly cookies, keeping credentials safe from XSS attacks.
  • Design a PostgreSQL chat schema with UUIDs, foreign keys, and performance indexes ready for real-time messaging and cursor-based pagination.
  • Implement security middleware including bcrypt password hashing, rate limiting on auth routes, and CORS configuration to protect your API from brute-force attacks and cross-origin abuse.
  • Secret Mission: Implemented refresh token rotation with theft detection, a production security pattern that revokes all sessions when a stolen token is reused.

Ready to quiz yourself?