Add Authentication and RBAC to Your API

Secure a REST API with JWT authentication and role-based access control.

Introduction

30 Second Summary

Imagine leaving your front door wide open with a sign that says "take whatever you want." That is what a public API without authentication looks like. Anyone can read, edit, or delete your data without proving who they are.

In this project, you will add a complete security layer to your existing Student Course Management API. You will build user registration with hashed passwords, login with signed tokens, and role-based permissions that control who can do what.

What You'll Build

You will fire off a request to your API, get rejected with a clear 401, log in to receive a token, retry with that token attached, and watch the data flow back. Then you will switch to a Student-role account and watch the API block you from deleting a resource that only Admins can touch.

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

  • A register and login system that hashes passwords with bcrypt and issues signed JSON Web Tokens proving who you are.
  • Reusable authentication middleware that rejects any request missing a valid token, protecting every endpoint in your API with a single line of code.
  • A role-based access control layer where Admins can create, update, and delete resources while Students can only read them.
  • Secret Mission: self-service endpoints for viewing your own profile and changing your password with old-password verification.

Are there any prerequisites?

This project builds directly on your Student Course Management REST API from Project 2. You should already have a working API with Students, Courses, and Enrollments tables, PostgreSQL raw SQL queries, and a layered architecture (controllers, services, routes).

Before We Start

Before we dive in, let's lock in what your API is currently missing and why that gap would be dangerous in a real deployment.

Install Auth Dependencies and Configure Environment Variables

Your API handles students, courses, and enrollments. But right now, every endpoint is wide open. Anyone on the network can create, read, update, or delete any record without proving who they are.

Before you can identify users or restrict access, you need two things: a library that hashes passwords so they never touch the database in plain text, and a library that signs and verifies identity tokens. This step installs both and configures your secret values in a .env file that Bun reads automatically at startup.

In this step, get ready to:
  • Install bcryptjs and jsonwebtoken packages with Bun.
  • Create a .env file with JWT and database configuration.
  • Verify Bun reads your environment variables automatically.
Install the auth packages

Your project needs two new packages that handle cryptography and token management.

  • Install bcryptjs and jsonwebtoken by running this command in your VS Code terminal:
bun add bcryptjs jsonwebtoken

What do these packages do?

  • bcryptjs is a pure-JavaScript implementation of the bcrypt password hashing algorithm. It turns plaintext passwords into irreversible hashes with no native build dependencies.
  • jsonwebtoken signs and verifies JSON Web Tokens (JWTs). These tokens let the server confirm a user's identity on every request without re-checking the database.

You do not need a dotenv package. Bun reads .env files automatically at startup and populates process.env for you.

  • Confirm the install succeeded. You should see output listing the two packages and a message like installed bcryptjs@3.0.3 (the exact versions may vary).

Seeing a permission error or install failure?

  • Make sure your terminal is open inside your project folder (the one containing package.json). Run ls and confirm you see package.json in the output.
  • If Bun is not installed, revisit the setup in Project 2. You can verify by running bun --version.
  • Help me fix a bun add error
Create the environment configuration file

Environment variables keep sensitive values like database passwords and signing keys separate from your code. If you commit secrets to Git, anyone with repository access can see them.

  • In the VS Code Explorer sidebar, right-click your project root folder.
  • Select New File.
  • Name the file .env (including the leading dot).
  • Paste the following configuration into the file:
PORT=3000
DB_USER=postgres
DB_HOST=localhost
DB_NAME=student_course_db
DB_PASSWORD=your-db-password-here
DB_PORT=5432
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-use-a-long-random-string
JWT_EXPIRES_IN=24h

What do these variables control?

  • PORT through DB_PORT are your existing PostgreSQL connection settings. Replace your-db-password-here with the actual password you set for your PostgreSQL postgres user in Project 2.
  • JWT_SECRET is the key used to sign every token your server issues. In production, this would be a long random string generated by a password manager. For development, the placeholder value works.
  • JWT_EXPIRES_IN controls how long a token stays valid. 24h means users must log in again after 24 hours.
  • Replace your-db-password-here with your actual PostgreSQL password from Project 2.
  • Save .env.

You should see .env appear in your VS Code Explorer sidebar alongside package.json.

  • Now protect this file from being committed to Git by running this command in your terminal:
echo .env >> .gitignore

Why add .env to .gitignore?

Your .env file contains your database password and JWT signing key. Adding it to .gitignore ensures Git never tracks or commits it, even by accident. This is the first rule of application security: secrets never enter version control.

🙋‍♀️ Don't see .env in the Explorer?

  • Files starting with a dot are hidden by default on some systems. In VS Code, they should still appear in the Explorer. If they don't, click File then Open Folder and re-open your project root to refresh the file list.
  • Help me find my .env file
Verify Bun reads your environment variables

Unlike Node.js, Bun reads .env files automatically when it starts. No configuration or extra packages required. Your variables are immediately available in process.env.

Why no dotenv?

In a Node.js project, you would install the dotenv package and call require('dotenv').config() at the top of your entry point. Bun eliminates this step entirely. It detects your .env file and loads every variable into process.env before your code runs.

Before you run the verification command below, what do you expect it to print if everything is configured correctly?

  • Verify that both packages load and the environment variable is accessible by running this command:
bun -e "import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; console.log('JWT_SECRET:', process.env.JWT_SECRET ? 'loaded' : 'MISSING')"

You should see JWT_SECRET: loaded printed to the terminal. This confirms both packages are installed and your .env file is being read correctly by Bun.

Seeing JWT_SECRET: MISSING or a module not found error?

  • If you see MISSING: make sure your .env file is saved in the same folder where you ran the command (your project root, next to package.json). Run ls to confirm both files are listed.
  • If you see Cannot find module 'bcryptjs': the install did not complete successfully. Re-run bun add bcryptjs jsonwebtoken and check for errors in the output.
  • Check that JWT_SECRET in your .env file has no spaces around the = sign and no trailing whitespace.
  • Help me debug my environment variable setup

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file and that .gitignore includes .env.

ⓧ I'd like to double check the full code

Here is the complete .env file you should have at this point:

PORT=3000
DB_USER=postgres
DB_HOST=localhost
DB_NAME=student_course_db
DB_PASSWORD=your-db-password-here
DB_PORT=5432
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-use-a-long-random-string
JWT_EXPIRES_IN=24h

Your .gitignore file should contain a line with .env listed. No changes to your entry point file are needed for environment variables.

Your project now has the cryptographic tools and configuration it needs. Next up, you will create the Users table and build a registration endpoint that hashes passwords with bcrypt before storing them.

Create the Users Table and Registration Endpoint

Your API now has environment variables loaded and auth libraries installed, but there is nowhere to store user identities.

In this step you will create a users table in PostgreSQL, build a service layer that hashes passwords with bcrypt, and wire up a registration endpoint. Storing plaintext passwords would be catastrophic because a single database breach would expose every credential.

By the end of this step, you will see a password stored as an unreadable hash in the database.

In this step, get ready to:
  • Create a Users table with hashed-password storage and role assignments.
  • Build a user service that hashes passwords before writing them to the database.
  • Build a registration endpoint and test it end-to-end.
Create the Users table in PostgreSQL

Your existing database holds Students, Courses, and Enrollments. Now it needs a table to track who can access the API and what role they hold.

  • Open your PostgreSQL client (psql in the terminal, pgAdmin, or whichever tool you used in Project 2).
  • Connect to your student_course_db database.
  • Create the Users table by running this SQL:
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(100) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  password VARCHAR(255) NOT NULL,
  role VARCHAR(20) DEFAULT 'student',
  created_at TIMESTAMP DEFAULT NOW()
);

What does this table do?

  • id auto-increments so every user gets a unique numeric identifier.
  • username and email are both UNIQUE to prevent duplicate accounts.
  • password stores a 60-character bcrypt hash, never a plaintext password.
  • role defaults to 'student' so every user has a baseline permission level unless explicitly set to 'admin'.
  • Confirm the table was created by running:
\dt

You should see users listed alongside your existing students, courses, and enrollments tables.

Don't see the users table?

  • Make sure you are connected to the correct database. Run \c student_course_db to reconnect.
  • Check for typos in the CREATE TABLE statement. PostgreSQL will report a syntax error with a line number if something is wrong.

Still stuck? Help me debug my CREATE TABLE statement for the users table.

Build the user service layer

Your existing project uses a layered architecture where services handle business logic and database queries. The user service follows the same pattern your student and course services use.

  • Create a new file src/services/user.service.ts and add the imports and createUser function:
import pool from '../config/db';
import bcrypt from 'bcryptjs';

export const createUser = async (
  username: string,
  email: string,
  password: string,
  role: string = 'student'
) => {
  // Generate a salt and hash the password before storing
  const salt = await bcrypt.genSalt(10);
  const hashedPassword = await bcrypt.hash(password, salt);

  // Insert user with hashed password, return everything except password
  const result = await pool.query(
    `INSERT INTO users (username, email, password, role)
     VALUES ($1, $2, $3, $4)
     RETURNING id, username, email, role, created_at`,
    [username, email, hashedPassword, role]
  );

  return result.rows[0];
};

What does this code do?

  • bcrypt.genSalt(10) generates a random salt with 10 rounds of processing. Higher rounds are slower but more secure.
  • bcrypt.hash(password, salt) produces a 60-character hash that combines the salt and encrypted password into one string.
  • The RETURNING clause deliberately excludes the password column so hashed passwords never leave the database layer.
  • Below the createUser function, add the query helpers and the exports:
export const findByEmail = async (email: string) => {
  // Returns full user record (including hash) for login comparison
  const result = await pool.query(
    'SELECT * FROM users WHERE email = $1',
    [email]
  );
  return result.rows[0];
};

export const findById = async (id: number) => {
  // Returns user data without password for profile lookups
  const result = await pool.query(
    'SELECT id, username, email, role, created_at FROM users WHERE id = $1',
    [id]
  );
  return result.rows[0];
};

What do these helpers do?

  • findByEmail returns the full user record (including the hashed password) so the login flow can compare credentials.
  • findById returns user data without the password. This is used for profile lookups where credentials are not needed.
  • Save src/services/user.service.ts.
  • Confirm the file exists by running:
ls src/services/

You should see user.service.ts listed alongside your existing service files.

File not showing up?

  • Make sure you saved the file inside the src/services/ directory, not in the project root.
  • Confirm the filename matches exactly: user.service.ts (not users.service.ts or userService.ts).

Still stuck? Help me confirm my file was created in the right location.

Build the auth controller and route

The controller receives HTTP requests, validates input, and delegates to the service layer. The route file maps URL paths to controller functions.

  • Create src/controllers/auth.controller.ts with the register function:
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import * as userService from '../services/user.service';
import { Request, Response } from 'express';

export const register = async (req: Request, res: Response) => {
  try {
    const { username, email, password, role } = req.body;
    // Validate required fields are present
    if (!username || !email || !password) {
      return res.status(400).json({ error: 'Username, email, and password are required.' });
    }
    // Check for duplicate email
    const existingUser = await userService.findByEmail(email);
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered.' });
    }
    // Only allow valid roles, default to student
    const allowedRoles = ['admin', 'student'];
    const userRole = allowedRoles.includes(role) ? role : 'student';
    const user = await userService.createUser(username, email, password, userRole);
    res.status(201).json({
      message: 'User registered successfully.',
      user
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Registration failed.', details: err.message });
  }
};

What does this controller do?

  • It validates that username, email, and password are present (returns 400 if not).
  • It checks for duplicate emails and returns 409 Conflict if one already exists.
  • It restricts the role to either 'admin' or 'student', defaulting to 'student' for any unrecognized value.
  • The bcrypt and jwt imports are included now because the login function (added in the next step) will use them.
  • Save src/controllers/auth.controller.ts.
  • Create src/routes/auth.routes.ts to define the registration route:
import { Router } from 'express';
import { register } from '../controllers/auth.controller';

const router = Router();

// Public route - no token required
router.post('/register', register);

export default router;
  • Save src/routes/auth.routes.ts.
  • Confirm both new files exist by running:
ls src/controllers/ src/routes/

You should see auth.controller.ts in the controllers list and auth.routes.ts in the routes list.

  • Now mount the auth routes in your main app file. Open app.ts (or server.ts) and find where your existing routes are mounted.
  • Add the following two lines alongside your other route imports and mounts:
// Import the auth routes
import authRoutes from './src/routes/auth.routes';

// Mount auth routes (public, no token needed)
app.use('/api/auth', authRoutes);

Adjust the import path if needed

If your entry point is inside the src/ folder rather than the project root, use './routes/auth.routes' instead. Match the pattern your existing student and course route imports use.

  • Save your app file.

Before you test the endpoint, what do you expect to see in the response when you register a new user? Will the password field be visible?

  • Start your server and send a test registration request by running:
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "admin1", "email": "admin@test.com", "password": "Password123", "role": "admin"}'

You should receive a 201 response containing the user object with id, username, email, role, and created_at. The password field is absent from the response.

  • Now verify the hash is stored correctly by querying the database directly in your PostgreSQL client:
SELECT id, username, email, password, role FROM users;

The password column should contain a bcrypt hash starting with $2a$. This confirms the plaintext password was hashed before it reached the database.

Getting a connection error or 404?

  • If you see Cannot POST /api/auth/register, the auth routes are not mounted. Double check that your app.use('/api/auth', authRoutes) line is placed after app.use(express.json()).
  • If you see a database connection error, confirm your PostgreSQL server is running and the .env credentials match your local setup.
  • If you get a "relation users does not exist" error, run the CREATE TABLE SQL from the first substep. You may have run it in the wrong database.

Still stuck? Help me debug my registration endpoint returning an error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: src/services/user.service.ts, src/controllers/auth.controller.ts, src/routes/auth.routes.ts, and your updated app file.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block is present in your files, in order:

  • src/services/user.service.ts: the createUser function (with imports at the top), followed by findByEmail and findById.
  • src/controllers/auth.controller.ts: imports at the top, the register function with validation, duplicate check, role handling, and user creation.

Here is the complete src/routes/auth.routes.ts:

import { Router } from 'express';
import { register } from '../controllers/auth.controller';

const router = Router();

router.post('/register', register);

export default router;

Your API now has its first auth capability: users can register with securely hashed passwords. Next up, you will build the login endpoint that verifies those credentials and issues a JWT.

Build the Login Endpoint That Issues a JWT

Your registration endpoint proves a user can create an account. But the API still has no way to identify who is making subsequent requests.

The login endpoint solves this. It verifies credentials against stored bcrypt hashes, then issues a signed JSON Web Token containing the user's identity and role. From this point on, every request carries a token instead of re-sending credentials.

In this step, get ready to:
  • Build the login function that validates credentials and signs a JWT.
  • Add the login route to the auth router.
  • Test both successful and failed login attempts.
Build the login function

The login function has two jobs: verify that the submitted credentials are correct, then issue a signed token if they are.

  • In src/controllers/auth.controller.ts, find the closing line of the file (after the register function).
  • Add the following login function below the register function:
export const login = async (req: Request, res: Response) => {
  try {
    // Extract email and password from request body
    const { email, password } = req.body;

    // Return 400 if either field is missing
    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required.' });
    }

    // Look up user by email
    const user = await userService.findByEmail(email);
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials.' });
    }

    // Compare submitted password against stored bcrypt hash
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ error: 'Invalid credentials.' });
    }

What does this code do?

This first half of the login function handles credential verification:

  • It returns 400 immediately if email or password is missing from the request body.
  • It uses the existing findByEmail service to look up the user. If no user exists with that email, it returns 401.
  • It uses bcrypt.compare to check the submitted password against the stored hash. If they don't match, it returns 401 with the same message.

Both wrong-email and wrong-password return the identical Invalid credentials. message. This prevents attackers from discovering which emails exist in your database.

  • Now add the token-signing and response portion directly below the code you just pasted (still inside the try block):
    // Sign a JWT with user identity and role
    const token = jwt.sign(
      { id: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET as string,
      { expiresIn: process.env.JWT_EXPIRES_IN }
    );

    // Return token and safe user info (no password)
    res.json({
      message: 'Login successful.',
      token,
      user: {
        id: user.id,
        username: user.username,
        email: user.email,
        role: user.role
      }
    });
  } catch (err: any) {
    res.status(500).json({ error: 'Login failed.', details: err.message });
  }
};

What does this code do?

Once credentials pass verification, this portion issues the token:

  • jwt.sign creates a signed token with a payload of {id, email, role}. This payload is what gets decoded from the token on every future request.
  • The secret and expiration come from your .env file. Bun loaded them into process.env automatically. The token expires after 24h as configured.
  • The as string assertion tells TypeScript that JWT_SECRET is definitely a string. In production you would validate this at startup.
  • The response includes both the token and a safe user object with no password field. The client stores this token and sends it with future requests.
  • Save src/controllers/auth.controller.ts.
Add the login route and test the flow

The login function exists but has no route pointing to it yet. One line in the router connects it to incoming requests.

  • In src/routes/auth.routes.ts, find this line:
import { register } from '../controllers/auth.controller';
  • Replace it with this to import both functions:
import { register, login } from '../controllers/auth.controller';
  • Find the register route line:
router.post('/register', register);
  • Add the following line directly below it:
router.post('/login', login);
  • Save src/routes/auth.routes.ts.
  • Restart your server so the new route loads.

Now test that login works. You registered a user in the previous step, so you can log in with those credentials.

  • Send a POST request to /api/auth/login with this JSON body (using Postman or curl):
{
  "email": "admin@test.com",
  "password": "Password123"
}

You should receive a 200 response containing a token field and a user object with no password.

What is inside the token?

The token value is a long string with three sections separated by dots. Each section is Base64-encoded JSON.

Copy your token and paste it into the debugger at jwt.io. You will see the decoded payload containing your user's id, email, and role. This is exactly the data your middleware will read from the token in the next step.

🙋‍♀️ Getting a 404 or cannot POST?

  • Make sure you restarted the server after saving the route file.
  • Confirm the URL is /api/auth/login and the method is POST, not GET.
  • Check that auth.routes.ts exports the router and that your main app file mounts it at /api/auth.

Still stuck? Why is my /api/auth/login route returning 404?

Test error cases

A login endpoint that only handles the happy path is incomplete. You need to confirm it rejects bad requests correctly.

  • Send a POST to /api/auth/login with the correct email but a wrong password:
{
  "email": "admin@test.com",
  "password": "WrongPassword"
}
  • You should receive a 401 response with Invalid credentials. as the error.
  • Now send a POST with an email that does not exist in your database:
{
  "email": "nobody@test.com",
  "password": "Password123"
}
  • You should receive the same 401 Invalid credentials. response. The message is identical so attackers cannot tell which part was wrong.
  • Finally, send a POST with the password field missing entirely:
{
  "email": "admin@test.com"
}
  • You should receive a 400 response with Email and password are required. as the error.

Why the same error message for wrong email and wrong password?

If the API returned "user not found" for a bad email and "wrong password" for a bad password, an attacker could probe your system to discover which email addresses have accounts. Using the same Invalid credentials. message for both prevents this user enumeration attack.

Before you move on, predict: what would happen if someone sent a valid token in the request body instead of credentials? Would the login succeed or fail?

It would fail with 400. The login endpoint only reads email and password from the body. Tokens are used to access other endpoints, not to log in again.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Your src/controllers/auth.controller.ts should contain the following sections in order. Scroll up and confirm each one is present:

  • The import statements at the top (bcryptjs, jsonwebtoken, user.service, Request/Response).
  • The register function (unchanged from the previous step).
  • The login function with credential verification and token signing.

Here is the complete src/routes/auth.routes.ts:

import { Router } from 'express';
import { register, login } from '../controllers/auth.controller';

const router = Router();

router.post('/register', register);
router.post('/login', login);

export default router;

Your API is now identity-aware. It can create users and verify who they are. Next up, you will build the middleware that reads these tokens from incoming requests and blocks anyone who does not have one.

Build Authentication Middleware and Protect Existing Routes

Your API can now issue tokens through the login endpoint. But those tokens don't actually do anything yet.

Every existing endpoint for Students, Courses, and Enrollments is still wide open. Anyone can hit them without identifying themselves. In this step, you'll create a reusable middleware that verifies the JWT from every request's Authorization header, then apply it to all existing routes.

In this step, get ready to:
  • Create the authentication middleware with token verification and role checking.
  • Apply the middleware to all existing Students and Courses route files.
  • Test the authentication wall by sending requests with and without tokens.
Create the verifyToken middleware

Express middleware is a function that sits between the incoming request and your route handler. Your verifyToken middleware will intercept every request, check for a valid JWT in the Authorization header, and either let the request through or reject it with a 401.

  • Create a new folder called middleware inside src/ by running:
mkdir src/middleware
  • Create a new file called auth.middleware.ts inside src/middleware/ and add the following code:
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

// Middleware to verify JWT from the Authorization header
export const verifyToken = (req: Request, res: Response, next: NextFunction) => {
  // Extract the Authorization header value
  const authHeader = req.headers['authorization'];
  // Split "Bearer <token>" and take just the token part
  const token = authHeader && authHeader.split(' ')[1];

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

  try {
    // Verify the token signature and decode the payload
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string);
    // Attach user data to the request for downstream handlers
    (req as any).user = decoded;
    next();
  } catch (err: any) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired.' });
    }
    return res.status(401).json({ error: 'Invalid token.' });
  }
};

What does this code do?

This middleware runs before your route handler on every protected request.

  • It reads the Authorization header and splits off the token after "Bearer ".
  • If no token exists, it immediately returns 401 so the request never reaches your controller.
  • jwt.verify checks the token's signature and expiration. If valid, the decoded payload (id, email, role) is attached to req.user.
  • The (req as any).user cast is needed because Express's default Request type does not include a user property. In a production app you would extend the type.
  • The catch block distinguishes expired tokens from tampered tokens so the client gets a helpful error message.
Add the authorize middleware for role checking

Token verification proves WHO someone is. You also need a way to check WHAT they're allowed to do. The authorize middleware checks the role embedded in the decoded token against a list of allowed roles.

  • Add the following code below your verifyToken function in src/middleware/auth.middleware.ts:
// Middleware factory that checks user role against allowed roles
export const authorize = (...allowedRoles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!(req as any).user) {
      return res.status(401).json({ error: 'Not authenticated.' });
    }

    if (!allowedRoles.includes((req as any).user.role)) {
      return res.status(403).json({
        error: 'Forbidden. Insufficient permissions.',
        required_roles: allowedRoles,
        your_role: (req as any).user.role
      });
    }

    next();
  };
};

What does this code do?

  • authorize uses rest parameters (...allowedRoles: string[]) so you can pass one or more roles when you apply it to a route.
  • It returns a middleware function that checks (req as any).user.role against the allowed list. If the role is not permitted, it returns 403 with a clear message.
  • You won't use authorize until the next step, but exporting it now keeps the middleware file complete.
  • Save src/middleware/auth.middleware.ts.
  • Confirm the file exists by running:
ls src/middleware/

You should see auth.middleware.ts listed in the output.

Don't see the file?

  • Make sure you created the middleware folder inside src/, not in the project root.
  • Check that the filename is exactly auth.middleware.ts with no typos.

Help me find my auth middleware file.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Your src/middleware/auth.middleware.ts has two sections. Scroll up and confirm each is present in this order:

  • The import statements at the top (jsonwebtoken, Request, Response, NextFunction), followed by the full verifyToken function.
  • The authorize function below it.
Apply verifyToken to existing route files

The middleware exists but no route uses it yet. You need to import it in each route file and apply it with router.use(verifyToken) so every route in that file requires a valid token.

  • Open src/routes/student.routes.ts and replace its contents with the following:
import { Router } from 'express';
import * as studentController from '../controllers/student.controller';
// Import the auth middleware
import { verifyToken, authorize } from '../middleware/auth.middleware';

const router = Router();

// Apply token verification to ALL routes in this file
router.use(verifyToken);

router.get('/', studentController.getAllStudents);
router.get('/:id', studentController.getStudentById);
router.post('/', studentController.createStudent);
router.put('/:id', studentController.updateStudent);
router.delete('/:id', studentController.deleteStudent);

export default router;

What does router.use(verifyToken) do?

router.use(verifyToken) applies the middleware to every route defined on this router. Any request to /api/students or /api/students/:id must pass through token verification first.

Because router.use is placed before the route definitions, the middleware runs first. Order matters in Express.

  • Save src/routes/student.routes.ts.
  • Now open src/routes/course.routes.ts and replace its contents with the same pattern:
import { Router } from 'express';
import * as courseController from '../controllers/course.controller';
// Import the auth middleware
import { verifyToken, authorize } from '../middleware/auth.middleware';

const router = Router();

// Apply token verification to ALL routes in this file
router.use(verifyToken);

router.get('/', courseController.getAllCourses);
router.get('/:id', courseController.getCourseById);
router.post('/', courseController.createCourse);
router.put('/:id', courseController.updateCourse);
router.delete('/:id', courseController.deleteCourse);

export default router;
  • Save src/routes/course.routes.ts.
  • If you have a separate enrollment routes file, apply the same pattern there: import verifyToken and add router.use(verifyToken) before the route definitions.

Why are auth routes left unprotected?

Your src/routes/auth.routes.ts file does NOT get this middleware. A user needs to register and log in before they have a token, so those endpoints must remain public.

🙋‍♀️ Seeing an import path error?

  • The import path ../middleware/auth.middleware assumes your route files are in src/routes/. If your project structure differs, adjust the relative path accordingly.
  • Check that the file is named exactly auth.middleware.ts inside src/middleware/.

Help me fix my middleware import path.

Test the authentication wall

Your middleware is in place. Every request to your Students and Courses endpoints now passes through verifyToken before reaching the controller.

  • Start your server by running:
bun src/app.ts

Before you test, think about this: what do you expect to happen if you send a GET request to /api/students without any Authorization header? Will it succeed or fail?

  • Send a GET request to /api/students without any Authorization header. In a new terminal, run:
curl http://localhost:3000/api/students

You get a 401 response:

{"error":"Access denied. No token provided."}

That is exactly the designed outcome. Your previously-working endpoint now rejects unauthenticated requests. The middleware is doing its job.

Why is this a good thing?

Before this step, anyone could read, create, or delete data from your API. Now the door is locked. The only way through is with a valid token. This is exactly the behaviour you want in a production API.

  • Log in as your existing user to get a fresh token by running:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@test.com", "password": "Password123"}'
  • Copy the token value from the response.
  • Now retry the same GET request, this time including the token in the Authorization header:
curl http://localhost:3000/api/students \
  -H "Authorization: Bearer your-token-here"

Replace the token value

Replace your-token-here with the actual token string you copied from the login response. It will be a long string of letters, numbers, and dots.

You should now see the normal 200 response with your students data. The token unlocked the door.

  • Test with a garbage token to confirm the middleware catches invalid tokens:
curl http://localhost:3000/api/students \
  -H "Authorization: Bearer this-is-not-a-real-token"

You should see a 401 response with the message Invalid token. confirming the middleware rejects tampered tokens.

Still getting 200 without a token?

  • Make sure router.use(verifyToken) appears BEFORE your route definitions in student.routes.ts. Middleware order matters in Express.
  • Check that you saved both route files after editing them.
  • Restart your server. If you had it running while editing, the old code is still in memory.

Help me figure out why my routes are not protected.

Your API now has an authentication wall. No one can access your data without proving their identity first. Next up, you'll add role-based access control so that even authenticated users can only do what their role permits.

Add Role-Based Access Control to Write Operations

Your API now knows WHO is making each request. But it does not yet know WHAT each user is allowed to do. Right now, any authenticated user can create, update, or delete any resource.

The authorize middleware you built in the previous step already exists in auth.middleware.ts. In this step, you will wire it into your route files so that only admin-role users can perform write operations while any authenticated user can still read data.

In this step, get ready to:
  • Apply authorize('admin') middleware to all POST, PUT, and DELETE routes.
  • Test the complete RBAC flow with student and admin tokens.
  • Document the three auth states: no token (401), wrong role (403), authorized (success).
Apply authorize to write routes

Authentication answers "who are you?" and authorization answers "are you allowed to do this?" The authorize function you already have checks the role property embedded in the JWT against a list of allowed roles. If the role does not match, the request is rejected with a 403 status.

How does authorize work?

The authorize('admin') call returns a middleware function. When a request hits that middleware, it reads (req as any).user.role (set by verifyToken earlier in the chain) and checks whether it matches 'admin'.

If it does not match, the request never reaches your controller. It returns 403 immediately. No database lookup required because the role lives inside the token itself.

  • In VS Code, open src/routes/student.routes.ts.
  • Find these three lines:
router.post('/', studentController.createStudent);
router.put('/:id', studentController.updateStudent);
router.delete('/:id', studentController.deleteStudent);
  • Add authorize('admin') as the second argument in each route (between the path and the controller). The three lines should now look like this:
router.post('/', authorize('admin'), studentController.createStudent);
router.put('/:id', authorize('admin'), studentController.updateStudent);
router.delete('/:id', authorize('admin'), studentController.deleteStudent);

What does this change do?

Each write route now has two middleware guards in sequence. First verifyToken (applied via router.use at the top) confirms the token is valid. Then authorize('admin') confirms the user's role is admin. GET routes skip the second check, so any authenticated user can read.

  • Save student.routes.ts.
  • Now open src/routes/course.routes.ts.
  • Find the same three write-route lines:
router.post('/', courseController.createCourse);
router.put('/:id', courseController.updateCourse);
router.delete('/:id', courseController.deleteCourse);
  • Apply the same pattern. Add authorize('admin') to each write route:
router.post('/', authorize('admin'), courseController.createCourse);
router.put('/:id', authorize('admin'), courseController.updateCourse);
router.delete('/:id', authorize('admin'), courseController.deleteCourse);
  • Save course.routes.ts.
  • Restart your server by pressing Ctrl+C in the terminal and running:
bun src/app.ts

Server won't start?

Check that you did not accidentally remove the router.use(verifyToken) line at the top of each route file. The authorize middleware depends on (req as any).user being set first.

Also confirm that the import at the top of each route file still includes both: import { verifyToken, authorize } from '../middleware/auth.middleware'.

Help me debug my route file syntax error.

Test the full RBAC flow end-to-end

Your routes are now guarded. Time to prove it by testing all three auth states: no token, student token on a write operation, and admin token on a write operation.

  • First, register a student-role user by running this in a new terminal:
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "student1", "email": "student@test.com", "password": "Password123", "role": "student"}'

You should see a 201 response confirming the user was created with role student.

  • Log in as the student user to get a token:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "student@test.com", "password": "Password123"}'
  • Copy the token value from the response. You will use it in the next request.

Before you run the next command, what do you think will happen when a student-role user tries to delete a student record?

  • Try to delete a student using the student token (replace the token value with yours):
curl -X DELETE http://localhost:3000/api/students/1 \
  -H "Authorization: Bearer your-student-token-here"

You should see a 403 response:

{
  "error": "Forbidden. Insufficient permissions.",
  "required_roles": ["admin"],
  "your_role": "student"
}

Why 403 and not 401?

A 401 means "I do not know who you are." A 403 means "I know who you are, but you are not allowed to do this." The student token is valid (authentication passed), but the student role is not in the allowedRoles list for this route (authorization failed).

  • Confirm that read operations still work for the student. Run:
curl http://localhost:3000/api/students \
  -H "Authorization: Bearer your-student-token-here"

You should see a 200 response with the list of students. Read access is open to any authenticated user.

  • Now log in as the admin user you created earlier:
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@test.com", "password": "Password123"}'
  • Copy the admin token from the response.
  • Send the same DELETE request with the admin token:
curl -X DELETE http://localhost:3000/api/students/1 \
  -H "Authorization: Bearer your-admin-token-here"

This time the request passes both the authentication and authorization checks. You should see a success response (200 or the normal delete response from your controller).

The three auth states

Your API now enforces three distinct security levels on every write endpoint:

  • No token: 401 "Access denied. No token provided."
  • Valid token, wrong role: 403 "Forbidden. Insufficient permissions."
  • Valid token, correct role: request reaches the controller and succeeds.

🙋‍♀️ Student user getting 200 on write operations?

Make sure you added authorize('admin') as a separate argument before the controller function, not as a replacement for it. The correct pattern is three arguments: path, authorize middleware, then controller.

Also confirm you restarted the server after saving both route files. Bun does not hot-reload changes by default.

Help me figure out why my student can still write data.

✔️ Awesome, I've got everything!

Great. Double check you have saved both student.routes.ts and course.routes.ts.

ⓧ I'd like to double check the full code

Here is the full src/routes/student.routes.ts:

import { Router } from 'express';
import * as studentController from '../controllers/student.controller';
import { verifyToken, authorize } from '../middleware/auth.middleware';

const router = Router();

router.use(verifyToken);

router.get('/', studentController.getAllStudents);
router.get('/:id', studentController.getStudentById);

router.post('/', authorize('admin'), studentController.createStudent);
router.put('/:id', authorize('admin'), studentController.updateStudent);
router.delete('/:id', authorize('admin'), studentController.deleteStudent);

export default router;

Here is the full src/routes/course.routes.ts:

import { Router } from 'express';
import * as courseController from '../controllers/course.controller';
import { verifyToken, authorize } from '../middleware/auth.middleware';

const router = Router();

router.use(verifyToken);

router.get('/', courseController.getAllCourses);
router.get('/:id', courseController.getCourseById);

router.post('/', authorize('admin'), courseController.createCourse);
router.put('/:id', authorize('admin'), courseController.updateCourse);
router.delete('/:id', authorize('admin'), courseController.deleteCourse);

export default router;

Your API now enforces both authentication and role-based access control. Every request must prove identity, and write operations require the admin role. Next up in the Secret Mission, you will add self-service endpoints so users can view their own profile and change their password.

Secret mission

Add User Profile and Password Change Endpoints

Your auth system lets users register and log in, but what about self-service? In this mission, you'll add a GET /api/auth/me endpoint that returns the authenticated user's profile, plus a PATCH /api/auth/change-password endpoint with old-password verification. Both demonstrate how to scope database operations to the currently authenticated user.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • users table in your local PostgreSQL database.
  • .env file with JWT_SECRET and JWT_EXPIRES_IN.
  • Packages: bcryptjs and jsonwebtoken.
  • New auth source files: src/middleware/auth.middleware.ts, src/services/user.service.ts, src/controllers/auth.controller.ts, src/routes/auth.routes.ts.

Keep everything running

No action needed. Choose this if you are still actively building on your API or plan to continue to the next project in this series.

  • Your local PostgreSQL database continues running with the users table intact.
  • Your Bun server continues running (or can be restarted with bun src/app.ts).
  • The .env file stays in your project root. Remember it is excluded from version control via .gitignore.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database remain in place for when you return.

  • Stop the Bun server by pressing Ctrl+C in the terminal where it is running.
  • PostgreSQL can remain running in the background (it uses minimal resources). If you want to stop it too, use your system's service manager (e.g. brew services stop postgresql on macOS or sudo systemctl stop postgresql on Linux).
  • When you return, start PostgreSQL (if stopped) and run bun src/app.ts again.

Delete - I don't want to use this again

Remove the auth layer you built in this project. Your original Students, Courses, and Enrollments tables stay intact.

  • Drop the users table by connecting to your database and running this SQL:
psql -U postgres -d student_course_db -c "DROP TABLE IF EXISTS users;"
  • Delete the .env file from your project root:
rm .env
  • Uninstall the auth packages:
bun remove bcryptjs jsonwebtoken
  • Remove the auth source files:
rm src/middleware/auth.middleware.ts
rm src/services/user.service.ts
rm src/controllers/auth.controller.ts
rm src/routes/auth.routes.ts
  • Revert the middleware imports and authorize calls in src/routes/student.routes.ts and src/routes/course.routes.ts back to their original state (remove the verifyToken and authorize references).

Nice Work!

Nice Work!

You just built a complete security layer on top of your Student Course Management API. No one can read, create, update, or delete any resource without first proving who they are and that they have permission.

You've learned how to:

  • Build a Users table with bcrypt-hashed passwords and role assignments, plus Register and Login endpoints that validate credentials and issue signed JWTs.
  • Create reusable authentication middleware that verifies JWTs and populates req.user on every request, protecting all existing Students, Courses, and Enrollments endpoints.
  • Enforce role-based access control at the route level, restricting write operations to Admin users while keeping read access open to all authenticated users.
  • Secret Mission: Added self-service profile viewing and password change endpoints with old-password verification.

Ready to quiz yourself?