Role-Based Auth API with Express + MySQL

Build a session-based auth API with role-gated routes and bcrypt password hashing.

Introduction

30 Second Summary

Think about the last time you logged into a website. Behind the scenes, the server verified who you are, checked what you are allowed to do, and decided whether your session is still valid.

In this project, you will build an authentication API for a blog platform using Express and MySQL. You will implement session-based authentication with role-based access control, where the API checks the database on every request to decide what each user is allowed to do.

What You'll Build

When you finish, you can fire off requests from inside VS Code, register a user, log in, and watch your API accept or reject access to a protected route based on the user's role.

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

  • A working register and login flow where passwords are securely hashed and session cookies are stored as rows in your database.
  • A role-protected route that rejects a regular user, then instantly accepts the same user after a direct SQL promotion to "author" because the middleware checks the database fresh every time.
  • A logout endpoint that destroys the session row in MySQL, proving the old cookie is immediately useless.
  • An optional Secret Mission to push your skills further.

Are there any prerequisites?

This project assumes you already have Laragon and VS Code installed. Everything else, including Node.js and the Thunder Client extension, is covered in Step 1.

Before We Start

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

Set Up Your Dev Environment

You have committed to building a role-based auth API. Before you can write any API code, you need three things: a running MySQL server, Node.js available in your terminal, and a project folder with Express and its dependencies installed.

Laragon bundles both MySQL and Node.js, so you do not need to install them separately. In this step, you will start Laragon's services, create the project, and boot a minimal Express server.

In this step, get ready to:
  • Start Laragon and add Node.js to your system PATH.
  • Create the project folder and install all dependencies.
  • Create a minimal Express server and confirm it runs on port 3000.
Start Laragon and add Node.js to your PATH

Laragon manages MySQL and Node.js for you, but its tools are not visible to other programs until you add them to your system PATH. You will start Laragon's services first, then expose its tools to VS Code's terminal.

  • Press the Windows key to open your search bar.
  • Type Laragon and press Enter to open it.
  • Click Start All in the Laragon window.

What does Start All do?

Start All boots the services Laragon manages, including MySQL. You will see green status indicators once the services are running. Apache also starts, but this project only needs MySQL.

  • Right-click the Laragon window (or click Menu).
  • Select Tools.
  • Select Path.
  • Click Add Laragon to Path.

Why add Laragon to PATH?

Adding Laragon to your system PATH makes node and npm available in any terminal, including VS Code's integrated terminal. Without this step, your terminal would not recognize those commands.

Now switch to VS Code to confirm Node.js is accessible.

  • Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Check your Node.js version by running this command:
node -v

✔️ I see v22 or higher

You should see something like v22.x.x. Node.js is ready. On to the next substep.

ⓧ I see a lower version number

A version below 22 means your terminal is finding an older Node.js installation instead of Laragon's bundled version. Express 5.2.1 requires Node.js 18 or higher.

  • Close your VS Code terminal completely.
  • Go back to Laragon and repeat Menu > Tools > Path > Add Laragon to Path.
  • Open a fresh terminal in VS Code with Ctrl+` and run node -v again.

If the version is still below 22, the old Node.js is earlier on your system PATH. Uninstall the older version from Settings > Apps > Apps & features, restart VS Code, and try again.

ⓧ Command not found

Your terminal cannot find Node.js. This means the PATH was not updated successfully.

  • Close your VS Code terminal completely.
  • Go back to Laragon and repeat Menu > Tools > Path > Add Laragon to Path.
  • Open a fresh terminal in VS Code with Ctrl+` and run node -v again.

If it still fails, restart VS Code entirely so the terminal picks up the new PATH.

Create the project and install dependencies

With Node.js confirmed, you can create the project folder and pull in every package the API needs. You will use npm (Node's package manager) to initialise the project and install dependencies.

  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir blog-auth-api
cd blog-auth-api
  • Initialise a new Node.js project by running:
npm init -y

You should see a JSON output in the terminal showing the new package.json file it created.

Seeing 'npm is not recognized'?

This means the PATH update did not stick. Close the terminal, go back to Laragon, and repeat Menu > Tools > Path > Add Laragon to Path. Then open a fresh terminal and try again.

Help me troubleshoot npm not being recognized in VS Code's terminal.

  • Install the production dependencies by running:
npm install express@5.2.1 express-session@1.19.0 express-mysql-session@3.0.3 mysql2@3.23.3 bcryptjs@3.0.3 helmet@8.3.0 cors@2.8.6 dotenv@17.4.2 express-rate-limit@8.6.2

You should see output ending with something like "added XX packages". This may take a minute.

What did you just install?

  • express is the web framework that handles routes and middleware.
  • express-session and express-mysql-session manage login sessions and store them in MySQL.
  • mysql2 connects your app to the MySQL database.
  • bcryptjs hashes passwords so you never store them in plain text.
  • helmet, cors, and express-rate-limit handle security headers, cross-origin requests, and brute-force protection.
  • dotenv loads secret values from a .env file so they stay out of your code.
  • Install nodemon as a development dependency by running:
npm install --save-dev nodemon@3.1.9

What is nodemon?

Nodemon watches your files and automatically restarts the server whenever you save a change. The --save-dev flag marks it as a development-only tool, not something your production server needs.

Now you need to update package.json so it knows how to start your server, and create a .env file to hold your configuration values.

  • Open package.json in VS Code by clicking it in the Explorer sidebar (or press Ctrl+P and type package.json).
  • Find the "description", "main", and "scripts" sections near the top. They currently look like this:
"description": "",
"main": "index.js",
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace those three fields so they match this:
"description": "Role-based auth API for a blog platform",
"main": "src/server.js",
"scripts": {
  "dev": "nodemon src/server.js",
  "start": "node src/server.js"
},
  • Save package.json with Ctrl+S.

What did you change?

  • "main" now points to src/server.js, the entry point you will create shortly.
  • The "dev" script runs the server through nodemon so it auto-restarts on every save.
  • The "start" script runs the server directly with Node.js, which is what you would use in production.
  • Create a new file called .env in the root of your blog-auth-api folder. In VS Code's Explorer sidebar, click the New File icon and type .env.
  • Paste the following configuration into .env:
DB_HOST=localhost
DB_PORT=3306
DB_USER=blog_app
DB_PASSWORD=blog_app_password
DB_NAME=blog_platform

SESSION_SECRET=change-this-to-a-random-string-at-least-32-chars
PORT=3000
  • Save .env with Ctrl+S.

What is this .env file for?

The .env file stores configuration values that your code reads at startup. This keeps database credentials and secrets out of your source code. The DB_USER and DB_PASSWORD values will match the MySQL user you create in the next step.

  • Confirm your project files exist by running this in the terminal:
ls

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

Create the entry point and start the server

Your dependencies are installed and your configuration is ready. The last piece is the actual Express server file that ties everything together and listens for requests.

  • Create a src folder inside blog-auth-api. In VS Code's Explorer sidebar, click the New Folder icon and type src.
  • Inside the src folder, create a new file called server.js. Right-click the src folder and select New File.
  • Paste the following code into server.js:
// Load environment variables from .env
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

// Create the Express app
const app = express();

// Set security headers on every response
app.use(helmet());
// Allow requests from the React frontend (future project)
app.use(cors({
  origin: 'http://localhost:5173',
  credentials: true
}));
// Parse incoming JSON request bodies
app.use(express.json());

// Start the server on the port from .env (or 3000)
const PORT = process.env.PORT || 3000;

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

What does this code do?

  • require('dotenv').config() reads the .env file and makes its values available via process.env.
  • helmet() adds security headers to every response, protecting against common web attacks.
  • The CORS middleware is locked to http://localhost:5173 (Vite's default dev port). This prepares the API for the React frontend you will build in a future project.
  • express.json() parses JSON request bodies so your route handlers can read req.body directly.
  • Save server.js with Ctrl+S.

Before you run the server, what do you think you will see in the terminal? Will it succeed or fail?

  • Start the server by running:
npm run dev

You should see Server running on port 3000 printed in the terminal. Nodemon is now watching your files for changes.

Don't see 'Server running on port 3000'?

  • If you see a "Cannot find module" error, check that your file is saved at src/server.js (inside the src folder, not in the project root).
  • If you see a "port already in use" error, another process is using port 3000. Close other terminals or applications that might be running a server.
  • Make sure your .env file is in the root of blog-auth-api (next to package.json), not inside src.

Help me debug why my Express server won't start.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what each file should look like. Your package.json may have extra fields like keywords or license from npm init. Those are fine to keep.

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

const app = express();

app.use(helmet());
app.use(cors({
  origin: 'http://localhost:5173',
  credentials: true
}));
app.use(express.json());

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
DB_HOST=localhost
DB_PORT=3306
DB_USER=blog_app
DB_PASSWORD=blog_app_password
DB_NAME=blog_platform

SESSION_SECRET=change-this-to-a-random-string-at-least-32-chars
PORT=3000
{
  "name": "blog-auth-api",
  "version": "1.0.0",
  "description": "Role-based auth API for a blog platform",
  "main": "src/server.js",
  "scripts": {
    "dev": "nodemon src/server.js",
    "start": "node src/server.js"
  },
  "dependencies": {
    "bcryptjs": "3.0.3",
    "cors": "2.8.6",
    "dotenv": "17.4.2",
    "express": "5.2.1",
    "express-mysql-session": "3.0.3",
    "express-rate-limit": "8.6.2",
    "express-session": "1.19.0",
    "helmet": "8.3.0",
    "mysql2": "3.23.3"
  },
  "devDependencies": {
    "nodemon": "3.1.9"
  }
}

Your Express server is alive and listening for requests. Next up, you will create the MySQL database and wire it into the server so it has somewhere to store users and sessions.

Create the Database and Connect

Your Express server is running and ready to handle requests. You have helmet, CORS, and JSON parsing in place.

But the server has nowhere to store data. Your auth system needs a MySQL database to hold user accounts, hashed passwords, and role assignments before it can register or authenticate anyone. In this step, you will create the database schema and connect Express to it.

In this step, get ready to:
  • Create the blog_platform database with a users table and a dedicated MySQL user.
  • Build a connection pool module to link Express with MySQL.
  • Verify the full stack is connected and the database is ready for data.
Write and run the database schema

Before the API can store any users, MySQL needs a database with the right table structure. You will write a SQL schema file that creates the database, defines the users table, and sets up a dedicated MySQL user with limited permissions.

  • In VS Code's Explorer sidebar, right-click the project root (the same level as package.json) and select New File.
  • Name the file schema.sql and press Enter.
  • Paste the following SQL into schema.sql:
CREATE DATABASE IF NOT EXISTS blog_platform
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE blog_platform;

CREATE TABLE IF NOT EXISTS users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL UNIQUE,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('user', 'author', 'editor', 'admin') NOT NULL DEFAULT 'user',
  status ENUM('active', 'suspended', 'banned') NOT NULL DEFAULT 'active',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE USER IF NOT EXISTS 'blog_app'@'localhost' IDENTIFIED BY 'blog_app_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON blog_platform.* TO 'blog_app'@'localhost';
FLUSH PRIVILEGES;

What does this schema do?

  • CREATE DATABASE creates the blog_platform database with utf8mb4 encoding, which supports emojis and special characters.
  • The role column uses an ENUM type to restrict values to exactly four options: user, author, editor, and admin. The database itself rejects anything else.
  • The status column works the same way, limiting accounts to active, suspended, or banned.
  • CREATE USER and GRANT create a dedicated blog_app MySQL user that can only SELECT, INSERT, UPDATE, and DELETE. It cannot drop tables or create databases, which limits damage if credentials leak.
  • Save schema.sql.
  • In your VS Code terminal, stop the running server by pressing Ctrl+C.
  • Run the schema against MySQL by running this command:
mysql -u root < schema.sql

Getting an error with the < character?

If your terminal shows an error about <, your VS Code terminal is likely using PowerShell, which does not support the < redirection operator.

Press Ctrl+Shift+P to open the Command Palette, type Terminal: Select Default Profile, and choose Command Prompt. Open a new terminal with Ctrl+Shift+` and try the command again.

help me switch my VS Code terminal from PowerShell to Command Prompt

  • Confirm the tables were created by running this command:
mysql -u root -e "SHOW TABLES FROM blog_platform;"

You should see the users table listed in the output.

Don't see the users table?

  • Make sure Laragon's MySQL is running. Switch to the Laragon window and check that MySQL shows a green indicator.
  • Check that you ran the command from your project folder (where schema.sql lives). Run ls and confirm you see schema.sql in the listing.
  • my schema.sql ran but I don't see the users table in MySQL

✔️ Awesome, I've got everything!

Great. Make sure you have saved schema.sql and that the users table appeared in the output above.

ⓧ I'd like to double check the full code

Here is the complete schema.sql file. Compare it character-for-character with yours.

CREATE DATABASE IF NOT EXISTS blog_platform
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE blog_platform;

CREATE TABLE IF NOT EXISTS users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL UNIQUE,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('user', 'author', 'editor', 'admin') NOT NULL DEFAULT 'user',
  status ENUM('active', 'suspended', 'banned') NOT NULL DEFAULT 'active',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE USER IF NOT EXISTS 'blog_app'@'localhost' IDENTIFIED BY 'blog_app_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON blog_platform.* TO 'blog_app'@'localhost';
FLUSH PRIVILEGES;
Connect Express to MySQL

The database exists, but your Express app has no way to talk to it yet. You need a connection pool that keeps a set of reusable database connections ready. Opening a fresh connection for every request is slow. A pool keeps connections alive and hands them out as needed.

  • In VS Code's Explorer sidebar, right-click the src folder and select New File.
  • Type config/db.js and press Enter. VS Code creates the config folder automatically.
  • Paste the following code into src/config/db.js:
// Import mysql2's promise-based API for async/await support
const mysql = require('mysql2/promise');

// Create a pool of reusable database connections
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// Verify the pool can reach MySQL on startup
async function testConnection() {
  const connection = await pool.getConnection();
  console.log('Database connected');
  connection.release();
}

module.exports = { pool, testConnection };

What does this code do?

  • mysql.createPool() creates a pool of up to 10 connections using the credentials from your .env file. Instead of opening and closing a connection for every query, the pool hands out existing connections and recycles them when done.
  • testConnection() grabs one connection from the pool to prove MySQL is reachable, logs a confirmation, then releases the connection back to the pool.
  • Both pool and testConnection are exported so other files (like server.js and your future auth module) can use them.
  • Save src/config/db.js.

Now wire the connection pool into your server so it tests the database connection on startup.

  • In src/server.js, find this line near the top of the file:
const cors = require('cors');
  • Add this line directly below it:
const { pool, testConnection } = require('./config/db');
  • Now scroll to the bottom of src/server.js and find these lines:
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
  • Replace those three lines with the following:
// Test the database connection, then start the server
async function start() {
  await testConnection();
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
}

start();

Why wrap the listen call in an async function?

The start() function calls await testConnection() before starting the server. If MySQL is unreachable, the error surfaces immediately on startup instead of silently failing when the first user tries to register.

  • Save src/server.js.
  • Start the dev server by running this command:
npm run dev

You should see both Database connected and Server running on port 3000 in the terminal output.

Seeing a connection error instead?

  • Double check that Laragon's MySQL is still running. Switch to the Laragon window and confirm MySQL shows a green indicator.
  • Open your .env file and confirm DB_USER is blog_app and DB_PASSWORD is blog_app_password. These must match the credentials in schema.sql.
  • Make sure you ran mysql -u root < schema.sql successfully in the previous substep. The blog_app MySQL user must exist before the pool can connect.
  • my Express server can't connect to MySQL and I see a database error on startup

✔️ Awesome, I've got everything!

Great. Confirm both messages appear in the terminal and that you have saved both files.

ⓧ I'd like to double check the full code

Compare your files against these. They should match character-for-character.

const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

async function testConnection() {
  const connection = await pool.getConnection();
  console.log('Database connected');
  connection.release();
}

module.exports = { pool, testConnection };
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { pool, testConnection } = require('./config/db');

const app = express();

app.use(helmet());
app.use(cors({
  origin: 'http://localhost:5173',
  credentials: true
}));
app.use(express.json());

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

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

start();
Confirm the database is empty and ready

The server is connected, but is the users table actually there and empty? Before you run the next command, think about what a SELECT COUNT(*) on the users table should return. You have not registered any users yet.

  • Open a second terminal tab by pressing Ctrl+Shift+` (backtick). Keep the dev server running in the first tab.
  • Query the users table by running this command:
mysql -u root -e "SELECT COUNT(*) FROM blog_platform.users;"

You should see a count of 0. The table exists and is empty, ready for your first user.

Your database is connected and your schema is live. Next, you will build the registration and login endpoints that write real user data into this table.

Build Registration and Login

Your database is connected and the server starts cleanly. But right now, there is no way for anyone to create an account or prove who they are.

In this step, you will build the auth module. This module hashes passwords with bcryptjs, stores users in MySQL, and hands back session cookies so the server remembers who is logged in. By the end, you will send a register request and see a real session row appear in the database.

In this step, get ready to:
  • Build the auth service, controller, and routes for registration and login.
  • Configure session middleware with MySQL-backed session storage.
  • Install Thunder Client and test both endpoints.
Create the auth service layer

The service layer is where your database logic lives. It handles password hashing, user creation, and credential verification. Everything the controller needs, the service provides.

  • Create the folder structure for the auth module by running these commands in your VS Code terminal:
mkdir src/modules
mkdir src/modules/auth
  • Confirm the folders exist by running:
ls src/modules/auth

You should see an empty listing with no errors. The folder is ready.

  • Create a new file called auth.service.js inside src/modules/auth. Add the imports and the createUser function by pasting this code:
const bcrypt = require('bcryptjs');
const { pool } = require('../../config/db');

// Number of hashing rounds - higher is slower but more secure
const SALT_ROUNDS = 10;

// Hash the password and insert a new user into the database
async function createUser({ username, email, password }) {
  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);

  const [result] = await pool.execute(
    'INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)',
    [username, email, passwordHash]
  );

  return {
    id: result.insertId,
    username,
    email,
    role: 'user'
  };
}

What does this code do?

This function takes a plain-text password and hashes it using bcryptjs before storing anything in the database. The ? placeholders in the SQL query are parameterized queries. They prevent SQL injection by keeping user input separate from the query structure.

The returned object deliberately omits password_hash. The hashed password never leaves the service layer.

  • Below the createUser function in the same file, add the lookup and verification functions:
// Look up a user record by their email address
async function findByEmail(email) {
  const [rows] = await pool.execute(
    'SELECT id, username, email, password_hash, role, status FROM users WHERE email = ?',
    [email]
  );
  return rows[0] || null;
}

// Compare a plain-text password against the stored hash
async function verifyCredentials(email, password) {
  const user = await findByEmail(email);
  if (!user) return null;

  const valid = await bcrypt.compare(password, user.password_hash);
  if (!valid) return null;

  return user;
}

module.exports = { createUser, findByEmail, verifyCredentials };

What does this code do?

findByEmail runs a parameterized SELECT query and returns the first matching row, or null if no user exists with that email.

verifyCredentials uses bcrypt.compare() to check a plain-text password against the stored hash. It never decrypts the hash. Instead it hashes the incoming password and checks whether the two hashes match.

  • Save auth.service.js.
  • Verify the syntax is valid by running:
node --check src/modules/auth/auth.service.js

If you see no output at all, the syntax is correct. An error means a typo somewhere in the file.

Seeing a syntax error?

Check that every opening brace { has a matching closing brace }. Also confirm the module.exports line at the bottom lists all three function names exactly as written above.

Help me debug my auth.service.js syntax error

✔️ Awesome, I've got everything!

Great. Confirm you have saved auth.service.js before moving on.

ⓧ I'd like to double check the full code

Your src/modules/auth/auth.service.js should have two sections:

  • The imports, SALT_ROUNDS, and createUser function (first chunk you pasted).
  • The findByEmail, verifyCredentials, and module.exports line (second chunk you pasted).

Scroll through your file and confirm both sections are present, in order, with no duplicate lines between them.

Build the auth controller and routes

The controller sits between routes and the service. It reads the HTTP request, calls the service, and sends back the response.

  • Create a new file called auth.controller.js inside src/modules/auth. Start with the register function:
const authService = require('./auth.service');

// Handle new user registration
async function register(req, res) {
  const { username, email, password } = req.body;

  if (!username || !email || !password) {
    return res.status(400).json({ error: 'Username, email, and password are required' });
  }

  if (password.length < 8) {
    return res.status(400).json({ error: 'Password must be at least 8 characters' });
  }

  const existing = await authService.findByEmail(email);
  if (existing) {
    return res.status(409).json({ error: 'Email already registered' });
  }

  const user = await authService.createUser({ username, email, password });
  req.session.userId = user.id;

  res.status(201).json({
    user: { id: user.id, username: user.username, email: user.email, role: user.role }
  });
}

What does the register function do?

  • It validates that username, email, and password are all present, and that the password is at least 8 characters.
  • It checks whether the email is already taken before creating the user.
  • On success, it stores the new user's ID in req.session.userId. This is what creates the session. The server remembers this user is logged in.
  • The response includes user data but never the password_hash.
  • Below the register function, add the login function:
// Handle user login
async function login(req, res) {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

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

  if (user.status !== 'active') {
    return res.status(403).json({ error: `Account ${user.status}` });
  }

  req.session.userId = user.id;

  res.json({
    user: { id: user.id, username: user.username, email: user.email, role: user.role }
  });
}

What does the login function do?

It calls verifyCredentials to compare the submitted password against the stored hash. If the credentials are valid, it also checks the user's status column. A suspended or banned account gets a 403 even with the right password.

Like register, a successful login stores req.session.userId to create the session.

  • Save auth.controller.js.
  • Run a syntax check to make sure everything so far is clean:
node --check src/modules/auth/auth.controller.js

No output means the syntax is valid.

  • Below the login function in auth.controller.js, add logout, me, and the module exports:
// Handle user logout by destroying the session
async function logout(req, res) {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.clearCookie('blog_sid');
    res.json({ message: 'Logged out' });
  });
}

// Return the currently authenticated user's info
async function me(req, res) {
  res.json({
    user: {
      id: req.user.id,
      username: req.user.username,
      email: req.user.email,
      role: req.user.role
    }
  });
}

module.exports = { register, login, logout, me };

What do logout and me do?

logout calls req.session.destroy() to delete the session row from MySQL, then clears the cookie named blog_sid. Once the row is gone, the cookie is useless.

me returns the current user's profile. It reads from req.user, which will be populated by auth middleware you build in the next step.

  • Save auth.controller.js.
  • Run the syntax check again to confirm the full file is valid:
node --check src/modules/auth/auth.controller.js

✔️ Awesome, I've got everything!

Great. Confirm you have saved auth.controller.js before moving on.

ⓧ I'd like to double check the full code

Your src/modules/auth/auth.controller.js should have four sections in this order:

  • The require('./auth.service') import and the register function (first chunk).
  • The login function (second chunk).
  • The logout and me functions, followed by module.exports (third chunk).

Scroll through your file and confirm all sections are present, in order, with no duplicate lines.

Now you need two more files to connect everything. The routes file maps URL paths to controller functions, and a small middleware placeholder keeps the import working until you build the real auth middleware in Step 4.

  • Create the middleware folder and a placeholder file by running:
mkdir src/middleware
  • Create a new file called auth.js inside src/middleware. Paste this placeholder:
// Placeholder - full implementation in Step 4
async function requireAuth(req, res, next) {
  next();
}

module.exports = { requireAuth };

Why a placeholder?

The routes file you are about to create imports requireAuth from this path. The real version will query the database to check who the user is. For now, this pass-through keeps the server from crashing on startup. You will replace it in Step 4.

  • Create a new file called auth.routes.js inside src/modules/auth. Paste this code:
const express = require('express');
const router = express.Router();
const authController = require('./auth.controller');
const { requireAuth } = require('../../middleware/auth');

// Map HTTP methods and paths to controller functions
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/logout', authController.logout);
router.get('/me', requireAuth, authController.me);

module.exports = router;

What does this file do?

Each line maps a URL path to a controller function. When someone sends a POST to /register, Express calls authController.register. The /me route has requireAuth as middleware. That function runs before the controller, and will eventually verify the user is logged in.

  • Save both src/middleware/auth.js and src/modules/auth/auth.routes.js.
Configure session middleware and start the server

The auth module is built, but the server does not know about it yet. You need to add session middleware so Express can create and read session cookies, then mount the auth routes.

  • In src/server.js, find the import lines at the top. The last import line is currently:
const { pool, testConnection } = require('./config/db');
  • Add these three new import lines directly below it:
const session = require('express-session');
const MySQLStore = require('express-mysql-session')(session);
const authRoutes = require('./modules/auth/auth.routes');

What are these imports?

express-session is the session middleware for Express. It creates a session object on every request.

express-mysql-session stores session data in your MySQL database instead of in server memory. If the server restarts, sessions survive because they live in a database table.

  • In src/server.js, find the line app.use(express.json());. Add the session store configuration directly below it:
// Create a MySQL-backed session store
const sessionStore = new MySQLStore({
  clearExpired: true,
  checkExpirationInterval: 900000,
  expiration: 86400000,
  createDatabaseTable: true,
  schema: {
    tableName: 'sessions',
    columnNames: {
      session_id: 'session_id',
      expires: 'expires',
      data: 'data'
    }
  }
}, pool);

What does the session store do?

createDatabaseTable: true tells the library to automatically create a sessions table in MySQL if one does not exist. The second argument, pool, passes your existing database connection so the session store shares the same pool your app uses.

  • Directly below the sessionStore block, add the session middleware, the auth route mount, and a global error handler:
// Apply session middleware to every request
app.use(session({
  key: 'blog_sid',
  secret: process.env.SESSION_SECRET,
  store: sessionStore,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: false,
    sameSite: 'strict',
    maxAge: 86400000
  }
}));

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

// Global error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

What do these session options mean?

  • key: 'blog_sid' sets the cookie name the browser receives.
  • httpOnly: true prevents JavaScript in the browser from reading the cookie. This blocks a common attack called cross-site scripting (XSS).
  • secure: false allows cookies over plain HTTP during development. In production, you would set this to true to require HTTPS.
  • sameSite: 'strict' tells the browser to only send the cookie to requests from the same origin.
  • Save src/server.js.

Before you restart the server, what do you expect to see in the terminal? Think about what the session store does on first startup.

  • In your VS Code terminal, stop the server with Ctrl+C if it is running, then start it again by running:
npm run dev

You should see Database connected and Server running on port 3000 in the terminal. The session store silently creates the sessions table in MySQL behind the scenes.

Server crashing on startup?

  • If you see Cannot find module, double check that src/middleware/auth.js and src/modules/auth/auth.routes.js both exist and are saved.
  • If you see a MySQL connection error, confirm Laragon is running by checking for the green status indicators in the Laragon window.
  • If you see SESSION_SECRET errors, confirm your .env file has the SESSION_SECRET line.

Help me fix my Express server startup crash

✔️ Awesome, I've got everything!

Great. Make sure the server stays running for the next substep.

ⓧ I'd like to double check the full code

Your src/server.js should have these sections in order:

  • Eight require imports at the top: dotenv, express, helmet, cors, express-session, express-mysql-session, db, and authRoutes.
  • The app setup with helmet(), cors(), and express.json().
  • The sessionStore configuration block.
  • The app.use(session({...})) middleware.
  • The app.use('/api/auth', authRoutes) route mount.
  • The global error handler.
  • The start() function with testConnection() and app.listen().

Scroll through your file and confirm all sections are present in this order.

Test registration and login with Thunder Client

Thunder Client is a REST client that runs inside VS Code. You will use it to send HTTP requests to your API without leaving the editor.

  • Open the Extensions panel in VS Code by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Search for Thunder Client and click Install.
  • After installation, click the Thunder Client icon in the left sidebar (it looks like a lightning bolt).
  • Click New Request to open a new request tab.

Time to register your first user. Before you send this request, what do you expect back? A user object, or an error?

  • Change the method dropdown from GET to POST.
  • Enter this URL in the address bar:
http://localhost:3000/api/auth/register
  • Click the Body tab below the URL bar.
  • Select JSON as the content type.
  • Paste this JSON body:
{
  "username": "testuser",
  "email": "test@example.com",
  "password": "Password123!"
}
  • Click Send.

You should see a 201 Created response with a JSON body containing your user's id, username, email, and role. Notice there is no password_hash in the response.

  • Click the Headers tab in the response panel at the bottom.
  • Look for a Set-Cookie header. You should see blog_sid= followed by a long encoded string. That is your session cookie.

Getting a 400 or 500 error?

  • A 400 response means the JSON body is malformed or missing a field. Confirm you selected JSON in the Body tab and pasted the exact JSON above.
  • A 500 response usually means a database error. Check that Laragon's MySQL is running and that you ran schema.sql in the previous step.
  • A 409 response means the email is already registered. The register endpoint only allows one account per email.

Help me debug my register endpoint error

Now test logging in with the account you just created.

  • Click New Request in Thunder Client to open a fresh request tab.
  • Set the method to POST and enter this URL:
http://localhost:3000/api/auth/login
  • In the Body tab, select JSON and paste:
{
  "email": "test@example.com",
  "password": "Password123!"
}
  • Click Send.

You should see a 200 OK response with the same user object. The response headers include another Set-Cookie with blog_sid.

Finally, confirm the sessions are actually stored in MySQL.

  • Open Laragon's built-in terminal by clicking Terminal in the Laragon window.
  • Query the sessions table by running:
mysql -u root -e "SELECT session_id, expires FROM blog_platform.sessions;"

You should see one or two rows listing the session IDs and their expiration timestamps. These are the server-side sessions created by your register and login requests. The session data lives in MySQL, not in server memory.

Why store sessions in MySQL?

If the server restarts, in-memory sessions would be lost and every user would be logged out. Storing sessions in MySQL means they survive server restarts. It also means you can inspect and delete them with SQL queries.

Your auth module is live. Users can register, log in, and their sessions are stored safely in MySQL. Next up, you will discover why any logged-in user can currently access any route, and build middleware to enforce role-based access control.

Discover the Role Gap and Build requireRole()

Your API can register users and hand out session cookies. But right now, every logged-in user has the same power.

Nothing stops a regular user from accessing routes meant only for authors or admins. In this step, you will see that gap firsthand by building a stub route that any logged-in user can access. Then you will build middleware that checks the user's role fresh from the database on every request.

In this step, get ready to:
  • Create a stub "create post" route and expose the role gap.
  • Build requireAuth and requireRole() middleware that queries the database on every request.
  • Apply the middleware and verify role-based access control works instantly.
Add a stub "create post" route

On a real blog platform, only authors should be able to create posts. You are going to build a stub route that checks whether a user is logged in but does not check their role. This will expose the gap.

  • Create a new folder src/modules/posts inside your project.
  • Create a new file src/modules/posts/posts.routes.js and paste the following code:
const express = require('express');
const router = express.Router();

// Stub route - only checks if a session exists
router.post('/', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not logged in' });
  }
  // Any logged-in user can reach this
  res.status(201).json({ message: 'Post created (stub)' });
});

module.exports = router;

What does this code do?

This route checks req.session.userId to see if the user is logged in. If they are, it lets them through. It does not check the user's role at all.

Now you need to mount this route in your server. The posts route needs to be importable and accessible at /api/posts.

  • Open src/server.js.
  • Find this line near the top of the file:
const authRoutes = require('./modules/auth/auth.routes');
  • Add this line directly below it:
const postRoutes = require('./modules/posts/posts.routes');
  • Now find this line further down in the file:
app.use('/api/auth', authRoutes);
  • Add this line directly below it:
app.use('/api/posts', postRoutes);
  • Save src/server.js and src/modules/posts/posts.routes.js.

Nodemon restarts automatically. You should see "Server running on port 3000" and "Database connected" in the terminal.

Server crashed?

Make sure the src/modules/posts folder exists and posts.routes.js is inside it. The require path in server.js must match the file location exactly.

Help me debug why my posts route isn't loading

Time to test. You are going to log in as a regular user and try to create a post.

  • Open Thunder Client in VS Code by clicking its icon in the left sidebar.
  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/auth/login
  • Click the Body tab, select JSON, and paste this:
{
  "email": "test@example.com",
  "password": "Password123!"
}
  • Click Send. You should see a 200 response with your user data. Thunder Client automatically stores the session cookie.

Now, while still logged in, try hitting the "create post" route. Before you send this request, think about it: your user's role is user, not author. Should a regular user be allowed to create posts?

  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/posts
  • Leave the body empty and click Send.

You get back a 201 response with { "message": "Post created (stub)" }. A regular user just "created" a post. That is the gap. The route only checks whether someone is logged in. It does not check whether they have the right role.

Why is this a problem?

On a blog platform, roles exist for a reason. Regular users read content. Authors create posts. Editors review them. Admins manage everything.

Without role checking, any logged-in user can do anything. Your API needs middleware that sits between the request and the route handler, verifying the user's role before letting them through.

Build the requireAuth and requireRole() middleware

You need two pieces of middleware to close the gap. The first, requireAuth, checks whether the user is logged in and fetches their current data from the database. The second, requireRole(), checks whether the user's role is one of the allowed roles for that route.

  • Create a new file src/middleware/auth.js and paste the requireAuth function:
const { pool } = require('../config/db');

async function requireAuth(req, res, next) {
  // Reject if no session exists
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  // Query the database for the user's CURRENT data on every request
  const [rows] = await pool.execute(
    'SELECT id, username, email, role, status FROM users WHERE id = ?',
    [req.session.userId]
  );

  // If user was deleted, destroy their session
  if (rows.length === 0) {
    req.session.destroy();
    return res.status(401).json({ error: 'User not found' });
  }

  const user = rows[0];

  // Block suspended or banned accounts immediately
  if (user.status !== 'active') {
    req.session.destroy();
    return res.status(403).json({ error: `Account ${user.status}` });
  }

  // Attach fresh user data for downstream middleware
  req.user = user;
  next();
}

Why query the database on every request?

Many auth systems cache the user's role in the session when they log in. That means if an admin demotes someone from author to user, the change does not take effect until that person logs out and back in.

By hitting the database on every request, role changes and account suspensions take effect immediately. No re-login required.

  • Add the requireRole() function and the module.exports below the requireAuth function in the same file:
// Check if the authenticated user has one of the allowed roles
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: `Requires role: ${roles.join(' or ')}`
      });
    }
    next();
  };
}

module.exports = { requireAuth, requireRole };

What is a higher-order function?

requireRole() is a function that returns another function. When you write requireRole('author', 'editor', 'admin'), it creates a middleware function that remembers those allowed roles. Express then calls that middleware function on each request.

  • Save src/middleware/auth.js.

Nodemon restarts. You should still see "Server running on port 3000" in the terminal. The middleware is loaded but not applied to any route yet.

Server not restarting?

Check that src/middleware/auth.js has the correct require path. The path ../config/db goes up one level from middleware/ to src/, then into config/.

Help me debug my auth middleware file

Apply the middleware and close the gap

The middleware exists but no route uses it yet. You need to update the posts route to run requireAuth and requireRole() before the handler.

  • Open src/modules/posts/posts.routes.js and replace the entire file with this updated version:
const express = require('express');
const router = express.Router();
const { requireAuth, requireRole } = require('../../middleware/auth');

// Only authors, editors, and admins can create posts
router.post('/', requireAuth, requireRole('author', 'editor', 'admin'), (req, res) => {
  res.status(201).json({ message: 'Post created (stub)' });
});

module.exports = router;

What changed?

The inline session check is gone. Instead, requireAuth runs first to verify the session and fetch fresh user data from the database. Then requireRole('author', 'editor', 'admin') checks whether the user's role is in the allowed list.

Express runs middleware in order. If requireAuth rejects the request, requireRole never runs. If requireRole rejects, the route handler never runs.

  • Save src/modules/posts/posts.routes.js. Nodemon restarts the server.

Before you retest, predict: you are still logged in as the same user with the same session cookie. Their role is user. Do you think the "create post" request will succeed or fail this time?

  • Switch back to Thunder Client.
  • Send the same POST request to http://localhost:3000/api/posts again.

This time you get a 403 Forbidden response with { "error": "Requires role: author or editor or admin" }. Same user, same session. The only difference is the middleware now checking their role.

Why does this prove the middleware works?

The user did not log out and log back in. The session cookie is the same one from before. But requireAuth queried the database and found the user's role is user. Then requireRole compared that against the allowed roles and blocked the request.

Now test the other direction. If you promote the user's role directly in the database, the very next request should succeed without re-login. That is the power of querying the role fresh on every request.

  • Open Laragon and click Terminal to open Laragon's built-in terminal.
  • Promote testuser to author by running this command:
mysql -u root -e "UPDATE users SET role = 'author' WHERE username = 'testuser';" blog_platform
  • Confirm the update took effect by running:
mysql -u root -e "SELECT id, username, role FROM users WHERE username = 'testuser';" blog_platform

You should see role set to author for testuser.

Before you retry, predict: the user's role just changed in the database, but you did not log out or log back in. Will the next request pick up the new role?

  • Switch back to Thunder Client and send the same POST request to http://localhost:3000/api/posts one more time.

You get 201 with { "message": "Post created (stub)" }. The role change took effect immediately because requireAuth queried the database fresh on this request and found the updated author role.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/middleware/auth.js and src/modules/posts/posts.routes.js.

ⓧ I'd like to double check the full code

Here is the complete src/middleware/auth.js:

const { pool } = require('../config/db');

async function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  const [rows] = await pool.execute(
    'SELECT id, username, email, role, status FROM users WHERE id = ?',
    [req.session.userId]
  );

  if (rows.length === 0) {
    req.session.destroy();
    return res.status(401).json({ error: 'User not found' });
  }

  const user = rows[0];

  if (user.status !== 'active') {
    req.session.destroy();
    return res.status(403).json({ error: `Account ${user.status}` });
  }

  req.user = user;
  next();
}
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: `Requires role: ${roles.join(' or ')}`
      });
    }
    next();
  };
}

module.exports = { requireAuth, requireRole };

Here is the complete src/modules/posts/posts.routes.js:

const express = require('express');
const router = express.Router();
const { requireAuth, requireRole } = require('../../middleware/auth');

router.post('/', requireAuth, requireRole('author', 'editor', 'admin'), (req, res) => {
  res.status(201).json({ message: 'Post created (stub)' });
});

module.exports = router;

And make sure src/server.js has these two lines added. The import near the top:

const postRoutes = require('./modules/posts/posts.routes');

And the mount line after app.use('/api/auth', authRoutes):

app.use('/api/posts', postRoutes);

The role gap is closed. Your API now checks every user's role fresh from the database before letting them into protected routes. Role changes take effect on the very next request with zero downtime. Next up, you will add logout, a "who am I" endpoint, and rate limiting to lock down the login route.

Logout, Security Hardening, and Login Rate Limiting

In the last step, you built requireRole() middleware that checks the database on every request. Role changes take effect instantly, no re-login needed. But two security gaps remain.

If someone steals a session cookie, it stays valid indefinitely because there is no proof that logout actually destroys the session on the server. An attacker can also try unlimited passwords with no throttle. This step closes both gaps and adds a rate limiter to block brute-force login attempts.

In this step, get ready to:
  • Test logout and confirm the session row is deleted from MySQL.
  • Verify the /api/auth/me endpoint returns the current user's data.
  • Add login rate limiting to block brute-force attacks.
Test logout and confirm session destruction

The logout route already exists in your API from the previous step. The real question is: does calling it actually destroy the session row in MySQL, or does the cookie keep working? Time to find out.

  • Switch back to Thunder Client in VS Code.
  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/auth/login
  • Click the Body tab, select JSON, and paste this body:
{
  "email": "test@example.com",
  "password": "Password123!"
}
  • Click Send. You should see a 200 response with your user data.
  • In the response panel, click the Headers tab.
  • Find the Set-Cookie header. Copy the value after blog_sid= up to the first semicolon. Save it somewhere (a sticky note, a text file). You will need it in a moment.

Why copy the cookie value?

You are simulating a stolen cookie. After logout, this cookie should be useless because the server destroyed the session row. Copying it now lets you test that claim directly.

  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/auth/logout
  • Click Send. You should see a 200 response with the message Logged out.

Now use the old cookie to prove it has been invalidated.

  • Create a new request. Set the method to POST and the URL to:
http://localhost:3000/api/posts
  • Click the Headers tab in the request panel (not the response panel).
  • Add a new header with the name Cookie and the value blog_sid= followed by the cookie value you copied earlier.

Before you click Send, what do you think will happen? The cookie looks valid. Will the server accept it or reject it?

  • Click Send.

You get a 401 Unauthorized response with the message Authentication required. The cookie is syntactically correct, but the session row it points to no longer exists in MySQL. A stolen cookie is useless after logout.

Still getting a 200 response?

Make sure you actually sent the logout request before retrying with the old cookie. If you skipped that step, the session row still exists.

Also confirm you are setting the Cookie header manually in the request's Headers tab, not relying on Thunder Client's automatic cookie handling.

Why is my old session cookie still working after logout?

Test the /api/auth/me endpoint

The /api/auth/me endpoint returns the currently logged-in user's profile. The future React frontend will call this endpoint to check if the user is authenticated and hydrate the UI with their data.

  • First, log back in. Create a new POST request to:
http://localhost:3000/api/auth/login
  • Set the Body to JSON with the same credentials you used before and click Send. Confirm you see a 200 response.
  • Create a new GET request to:
http://localhost:3000/api/auth/me
  • Click Send. You should see a 200 response containing your user object with id, username, email, and role.

Now test what happens when no session exists.

  • Send a POST to http://localhost:3000/api/auth/logout to destroy your session.
  • Send the GET /api/auth/me request again.

You get a 401 Unauthorized with Authentication required. The requireAuth middleware blocks access before the handler runs.

Add login rate limiting

Right now an attacker can send thousands of login requests per second, trying common passwords until one works. A rate limiter caps how many login attempts a single IP address can make within a time window.

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

// Limit each IP to 10 login attempts per 15-minute window
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
  standardHeaders: 'draft-8',
  legacyHeaders: false
});

module.exports = { loginLimiter };

What does this code do?

The rateLimit function from express-rate-limit creates middleware that tracks requests per IP address. windowMs sets a 15-minute window. limit caps each IP at 10 requests in that window.

When someone exceeds the limit, the middleware short-circuits the request and returns the message object as the response body with a 429 status code. The standardHeaders: 'draft-8' option sends rate-limit info in response headers so clients know how many attempts remain.

  • Save src/middleware/rateLimiter.js.

Now wire the limiter into the login route.

  • Open src/modules/auth/auth.routes.js.
  • Find this line near the top of the file:
const { requireAuth } = require('../../middleware/auth');
  • Add this import directly below it:
const { loginLimiter } = require('../../middleware/rateLimiter');
  • Now find the login route line:
router.post('/login', authController.login);
  • Add loginLimiter as middleware before the controller, so the line looks like this:
router.post('/login', loginLimiter, authController.login);
  • Save src/modules/auth/auth.routes.js. Nodemon restarts your server automatically.

Why apply the limiter only to login?

Login is the only endpoint where brute-force attacks matter. Registration already rejects duplicate emails. Protected routes already require a valid session. Limiting login specifically targets the password-guessing attack surface without throttling legitimate API usage.

Time to test the limiter. What do you think will happen if you send 11 login requests in rapid succession?

  • In Thunder Client, return to your POST request to http://localhost:3000/api/auth/login with the JSON body from earlier.
  • Click Send rapidly 11 times in a row.

The first 10 requests succeed with 200 responses. The 11th returns 429 Too Many Requests with the message Too many login attempts. Try again in 15 minutes.. The rate limiter is working.

Not seeing a 429 on the 11th request?

Make sure you saved both rateLimiter.js and auth.routes.js. Check your VS Code terminal to confirm nodemon restarted the server after the save.

If the server restarted between your attempts, the rate limiter's in-memory counter reset. Try sending all 11 requests without pausing.

Why am I not getting rate limited after 10 login requests?

Reset the rate limiter for continued testing

The rate limiter stores its counter in memory. Saving any file restarts nodemon, which resets the counter. If you need to send more login requests for later tests, save any project file to trigger a restart.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/middleware/rateLimiter.js and src/modules/auth/auth.routes.js.

ⓧ I'd like to double check the full code

Here is the full src/middleware/rateLimiter.js:

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
  standardHeaders: 'draft-8',
  legacyHeaders: false
});

module.exports = { loginLimiter };

Here is the full src/modules/auth/auth.routes.js:

const express = require('express');
const router = express.Router();
const authController = require('./auth.controller');
const { requireAuth } = require('../../middleware/auth');
const { loginLimiter } = require('../../middleware/rateLimiter');

router.post('/register', authController.register);
router.post('/login', loginLimiter, authController.login);
router.post('/logout', authController.logout);
router.get('/me', requireAuth, authController.me);

module.exports = router;

Before wrapping up, there is one more piece to add. The admin role needs a way to see all registered users. This sets up the admin module that the Secret Mission will build on.

  • Create a new folder at src/modules/users/.
  • Create a new file at src/modules/users/users.routes.js and paste the following code:
const express = require('express');
const router = express.Router();
const { requireAuth, requireRole } = require('../../middleware/auth');

// Only admins can list all users
router.get('/users', requireAuth, requireRole('admin'), async (req, res) => {
  const { pool } = require('../../config/db');
  const [rows] = await pool.execute(
    'SELECT id, username, email, role, status, created_at FROM users'
  );
  res.json({ users: rows });
});

module.exports = router;

What does this code do?

This route returns a list of all registered users, but only if the requester has the admin role. The requireAuth and requireRole('admin') middleware chain ensures two checks happen before the handler runs: is the user logged in, and is their current role admin?

Notice the SELECT query deliberately excludes password_hash. Even admin endpoints should never return password data.

  • Save src/modules/users/users.routes.js.

Now mount the admin routes in the main server file.

  • Open src/server.js.
  • Find this line near the top:
const postRoutes = require('./modules/posts/posts.routes');
  • Add this import directly below it:
const adminRoutes = require('./modules/users/users.routes');
  • Now find this line further down:
app.use('/api/posts', postRoutes);
  • Add this line directly below it:
app.use('/api/admin', adminRoutes);
  • Save src/server.js. Nodemon restarts the server.

Verify the admin route is protected. Log in via Thunder Client and send a GET request to:

http://localhost:3000/api/admin/users

You should see a 403 Forbidden response because your user's role is not admin. The requireRole() middleware is doing its job, keeping admin-only data locked down.

Getting a 401 instead of 403?

A 401 means you are not logged in. Make sure you sent a login request first and that Thunder Client is including the session cookie. Try logging in again, then immediately retry the admin request.

Why am I getting 401 instead of 403 on the admin route?

Your auth API is now fully secured. Sessions are destroyed server-side on logout. Brute-force login attempts are throttled. Admin-only routes are locked down. Every request checks the database for the user's live role and status.

Secret mission

Admin User Management

Your blog platform has roles, but no way for an admin to manage them through the API. Right now, promoting a user to author requires a direct SQL update in the terminal. In this Secret Mission, you will build admin-only endpoints to list users, change roles, and suspend accounts, all through the API.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The blog_platform MySQL database (includes the users and sessions tables).
  • The blog_app MySQL user created for the application.
  • Your project folder on the Desktop with all source code and node_modules.
  • The Thunder Client extension in VS Code.

Keep everything running

No action needed. Choose this if you are still actively building or plan to start the next project in the series, which builds post CRUD and editorial workflows on top of this API.

  • Leave Laragon's MySQL running so your database stays accessible.
  • Leave your project folder and source code in place.
  • Keep Thunder Client installed for testing in the next project.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your database and code are preserved on disk.

  • Press Ctrl+C in VS Code's terminal to stop the dev server.
  • In Laragon, click Stop All to shut down MySQL.
  • When you are ready to resume, click Start All in Laragon, open your project folder in VS Code, and run npm run dev.

Delete - I don't want to use this again

Remove all project resources and start fresh. This drops your database, removes the MySQL user, and deletes all project files.

  • Press Ctrl+C in VS Code's terminal to stop the dev server.
  • Open Laragon's built-in terminal by clicking Terminal in the Laragon window.
  • Drop the database by running this command:
mysql -u root -e "DROP DATABASE blog_platform;"
  • Drop the blog_app MySQL user by running:
mysql -u root -e "DROP USER 'blog_app'@'localhost';"
  • In Laragon, click Stop All to shut down MySQL.
  • Open File Explorer and navigate to your Desktop.
  • Right-click your project folder and select Delete to remove all source code and node_modules.
  • To uninstall Thunder Client, open VS Code and press Ctrl+Shift+X to open the Extensions sidebar.
  • Search for Thunder Client, click on it, then click Uninstall.

Nice Work!

Nice Work!

You just built a production-grade role-based authentication API from scratch. Your Express server connects to MySQL, hashes passwords, manages sessions, and gates every protected route with a fresh database lookup.

Here's what you learned:

  • Built a session-based authentication system with bcryptjs password hashing and server-side sessions stored in MySQL, proving that a stolen cookie becomes useless the moment the session row is deleted.
  • Designed requireRole() middleware that queries the database on every request, so role changes via direct SQL or admin endpoints take effect instantly without requiring a re-login.
  • Added login rate limiting with express-rate-limit to block brute-force password attacks after 10 attempts per IP.
  • Completed an optional Secret Mission to push your skills further.

Ready to quiz yourself?