Build a Notes API with JWT Auth

Build a NestJS REST API with JWT auth, custom guards, and role-based access.

Introduction

30 Second Summary

Every app you use daily protects its data behind a login screen. Someone built the logic that checks your password, issues a token, and decides which doors that token opens.

In this project, you will build a Notes API in NestJS that starts completely open, then gets locked down piece by piece with JWT authentication and role-based access control so you feel exactly why each auth layer exists.

What You'll Build

You will have a live API where you can register a user, log in to get a token, and watch unauthenticated requests bounce off your custom-built guard in real time.

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

  • A Notes API where you can register a user, see the password stored as a bcrypt hash (never plaintext), log in, and receive a signed JWT access token that unlocks protected routes.
  • A custom AuthGuard you wrote from scratch that rejects any request without a valid token, returning a 401 Unauthorized response you can see in your terminal.
  • Role-based access control where admin users can delete any note while regular users can only manage their own, enforced by a custom decorator and guard you built yourself.
  • Secret Mission: implement refresh tokens with token rotation so users stay logged in without re-entering credentials every 15 minutes.

Are there any prerequisites?

This project assumes basic NestJS experience (modules, controllers, services) and TypeScript familiarity. If you have built a simple NestJS app before, you are ready to go.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any code hits the screen.

Scaffold the Project and Install Auth Packages

You need a running NestJS project with the authentication-related dependencies installed before you can build anything. This step gets the foundation in place.

You will install the NestJS CLI, scaffold a fresh project, and add the packages that handle JWT signing and password hashing. By the end, your dev server will be running and responding on port 3000.

In this step, get ready to:
  • Install the NestJS CLI and scaffold a new project.
  • Install the authentication dependencies.
  • Start the dev server and confirm the app is running.
Install the NestJS CLI and scaffold a new project

The NestJS CLI is a command-line tool that scaffolds projects, generates modules, and runs your app in development mode. You will install it globally so you can use the nest command from anywhere.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Install the NestJS CLI globally by running this command:
npm i -g @nestjs/cli
  • Verify the CLI installed correctly by running:
nest --version

You should see a version number printed (e.g. 11.0.24).

Seeing 'command not found'?

If nest --version fails, close and reopen your terminal so it picks up the new global binary.

If it still does not work, try running npx @nestjs/cli --version to confirm the package installed. Help me fix the NestJS CLI installation

  • Scaffold the new project by running this command:
nest new notes-auth-api

What will this command ask me?

The CLI will prompt you to choose a package manager (npm, yarn, or pnpm). Select npm and press Enter.

After you choose, the CLI creates a notes-auth-api folder with a full TypeScript starter project and installs all default dependencies automatically. This takes a minute or two.

  • Once the scaffold finishes, move into the project folder by running:
cd notes-auth-api
  • Confirm the project structure was created by running:
ls src

You should see files including main.ts, app.module.ts, app.controller.ts, and app.service.ts.

Install the authentication dependencies

Your project needs two additional packages for authentication. @nestjs/jwt handles signing and verifying JSON Web Tokens. bcryptjs hashes passwords so they are never stored in plaintext.

  • Install the runtime dependencies by running:
npm install @nestjs/jwt bcryptjs
  • Install the TypeScript type definitions for bcryptjs by running:
npm install -D @types/bcryptjs

Both commands will print an "added X packages" confirmation when they finish.

Why bcryptjs instead of bcrypt?

The native bcrypt package requires C++ compilation tools that can fail on Windows without extra setup. bcryptjs is a pure JavaScript implementation with an identical API that works everywhere out of the box.

Install command failing?

Make sure you are inside the notes-auth-api folder (not the parent Desktop). Run pwd to check your current directory.

If you see permission errors, try closing VS Code and reopening it. Help me fix this npm install error

Start the dev server and confirm everything works

The scaffolded project comes with a default endpoint that returns "Hello World!" on the root URL. Starting the dev server confirms that your project compiles and responds to HTTP requests.

  • Start the development server in watch mode by running:
npm run start:dev

What does start:dev do?

This command compiles your TypeScript and starts the HTTP server on port 3000. It also watches for file changes and automatically restarts when you save edits. Your terminal will stay occupied by this process while it runs.

Before you check the browser, what do you expect to see when you visit http://localhost:3000?

  • Open your browser and navigate to http://localhost:3000.

You should see the text "Hello World!" displayed in the browser. This confirms your NestJS app is compiled, running, and responding to HTTP requests.

Not seeing Hello World?

Check your terminal for compilation errors. If you see red error text, the TypeScript compiler found a problem.

Make sure port 3000 is not already in use by another application. You can stop other processes on that port or close other running servers.

If the terminal shows "Nest application successfully started" but the browser does not load, double check you typed the full URL including http://. Help me get my NestJS dev server working

Your project is scaffolded, your auth packages are installed, and the dev server is live. Next up, you will build the Notes CRUD endpoints so you have real API routes to protect.

Build the Unprotected Notes CRUD

Your NestJS project is scaffolded and the dev server confirmed everything compiles. Now you need actual API endpoints that do something useful.

In this step, you will build a complete Notes CRUD (Create, Read, Delete) API with no authentication. The endpoints are intentionally wide open so that in later steps you can see exactly what goes wrong when anyone with the URL can read, create, and delete data.

In this step, get ready to:
  • Generate the notes module, service, and controller with the NestJS CLI.
  • Build an in-memory data store with CRUD methods.
  • Wire up REST endpoints and confirm they respond without any credentials.
Generate the notes module, service, and controller

The NestJS CLI can scaffold module, service, and controller files in one shot. It also auto-registers each file in the nearest module so you do not have to wire imports manually.

  • Stop the running dev server by pressing Ctrl+C in your terminal.
  • Generate the notes module, service, and controller by running these commands:
nest generate module notes
nest generate service notes
nest generate controller notes

What do these commands do?

Each command creates a file inside a new src/notes/ folder.

  • nest generate module notes creates notes.module.ts and adds NotesModule to your app.module.ts imports.
  • nest generate service notes creates notes.service.ts and registers it as a provider in notes.module.ts.
  • nest generate controller notes creates notes.controller.ts and registers it as a controller in notes.module.ts.
  • Confirm the files were created by running:
ls src/notes

You should see notes.controller.ts, notes.module.ts, notes.service.ts, and their corresponding .spec.ts test files.

Now you will replace the empty service stub with your actual data logic.

  • Open src/notes/notes.service.ts in VS Code and replace its entire contents with the following:
import { Injectable, NotFoundException } from '@nestjs/common';

// Define the shape of a Note object
export interface Note {
  id: number;
  title: string;
  content: string;
  userId: number;
}

@Injectable()
export class NotesService {
  // In-memory storage (no database needed)
  private notes: Note[] = [];
  private idCounter = 1;

  // Return all notes
  findAll(): Note[] {
    return this.notes;
  }

  // Find a single note by ID
  findOne(id: number): Note {
    const note = this.notes.find((n) => n.id === id);
    if (!note) {
      throw new NotFoundException(`Note with id ${id} not found`);
    }
    return note;
  }
}

What does this code do?

  • The Note interface defines the shape of every note: an ID, title, content, and the ID of the user who created it.
  • The notes array acts as an in-memory database. Data disappears when the server restarts, which is fine for learning auth patterns.
  • findOne() throws a NotFoundException (HTTP 404) if the requested note does not exist.
  • Add the create and remove methods inside the NotesService class, directly below the findOne method (before the final closing brace):
  // Create a new note and return it
  create(title: string, content: string, userId: number): Note {
    const note: Note = {
      id: this.idCounter++,
      title,
      content,
      userId,
    };
    this.notes.push(note);
    return note;
  }

  // Remove a note by ID
  remove(id: number): void {
    const index = this.notes.findIndex((n) => n.id === id);
    if (index === -1) {
      throw new NotFoundException(`Note with id ${id} not found`);
    }
    this.notes.splice(index, 1);
  }

What does this code do?

  • create() builds a note object with an auto-incrementing ID, pushes it into the array, and returns it so the API can show the caller what was created.
  • remove() finds the note's index and splices it out. If the note does not exist, it throws a 404 just like findOne().
  • Save notes.service.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your notes.service.ts should contain these sections in order:

  • The import line bringing in Injectable and NotFoundException.
  • The exported Note interface with id, title, content, and userId fields.
  • The NotesService class with notes array and idCounter properties.
  • The findAll() and findOne() read methods.
  • The create() and remove() write methods.
Wire up the controller with CRUD endpoints

The service handles data logic, but it cannot receive HTTP requests on its own. The controller maps incoming requests to service methods and returns responses.

  • Open src/notes/notes.controller.ts and replace its entire contents with the following:
import {
  Controller,
  Get,
  Post,
  Delete,
  Body,
  Param,
  ParseIntPipe,
} from '@nestjs/common';
import { NotesService } from './notes.service';

@Controller('notes')
export class NotesController {
  constructor(private notesService: NotesService) {}

  // GET /notes - return all notes
  @Get()
  findAll() {
    return this.notesService.findAll();
  }

  // GET /notes/:id - return a single note
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.notesService.findOne(id);
  }
}

What does this code do?

  • @Controller('notes') prefixes every route in this class with /notes.
  • The constructor injects NotesService automatically through NestJS dependency injection.
  • ParseIntPipe converts the :id URL parameter from a string to a number. If someone passes a non-numeric ID, NestJS returns a 400 Bad Request.
  • Add the POST and DELETE endpoints inside the NotesController class, below the findOne method (before the final closing brace):
  // POST /notes - create a new note
  @Post()
  create(@Body() body: { title: string; content: string }) {
    return this.notesService.create(body.title, body.content, 0);
  }

  // DELETE /notes/:id - remove a note
  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    this.notesService.remove(id);
    return { message: `Note ${id} deleted` };
  }

What does this code do?

  • @Body() extracts the JSON request body. The type annotation tells TypeScript to expect title and content fields.
  • The 0 passed as userId is a placeholder. There is no logged-in user yet, so every note gets user ID 0. A later step replaces this with the real user from the JWT.
  • The DELETE handler returns a confirmation message after removing the note.
  • Save notes.controller.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your notes.controller.ts should contain these sections in order:

  • Imports from @nestjs/common (Controller, Get, Post, Delete, Body, Param, ParseIntPipe) and the NotesService import.
  • The class declaration with @Controller('notes') and the constructor injecting NotesService.
  • GET /notes and GET /notes/:id handlers.
  • POST /notes and DELETE /notes/:id handlers.

One last cleanup before you restart. The CLI already added NotesModule to app.module.ts, but the old AppController and AppService are still referenced there.

  • Open src/app.module.ts and find these lines:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { NotesModule } from './notes/notes.module';

@Module({
  imports: [NotesModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • Replace the entire file contents with this simplified version that only imports NotesModule:
import { Module } from '@nestjs/common';
import { NotesModule } from './notes/notes.module';

@Module({
  imports: [NotesModule],
})
export class AppModule {}
  • Save app.module.ts.
  • Restart the dev server by running:
npm run start:dev

You should see the app compile successfully with the message "Nest application successfully started".

Before you test, what do you think GET /notes will return right now? The server just started fresh with an empty in-memory array.

  • Open a new terminal tab and test the GET endpoint by running:
curl http://localhost:3000/notes

You should see an empty array: [].

Seeing an error instead of []?

  • If you see "Cannot GET /notes", check that NotesModule is listed in the imports array of app.module.ts.
  • If the server did not start, look at the terminal for a compile error. A missing import or typo in the service or controller is the most common cause.
  • Make sure your second terminal is a separate tab from the one running the dev server.

help me debug why curl http://localhost:3000/notes is not returning an empty array

Create a note and verify the full flow

The GET endpoint works, but an empty array does not prove much. Time to create a note and confirm the full CRUD cycle.

  • Create a note by running this command in your second terminal:
curl -X POST http://localhost:3000/notes -H "Content-Type: application/json" -d "{\"title\":\"My Note\",\"content\":\"Hello world\"}"

You should see the created note returned with an auto-assigned ID:

{"id":1,"title":"My Note","content":"Hello world","userId":0}

Why is userId 0?

There is no authentication yet, so the controller passes a hardcoded 0 as the user ID. Once you build the auth system in later steps, this value will come from the logged-in user's JWT.

  • Fetch all notes again to confirm it was stored:
curl http://localhost:3000/notes

You should now see an array containing your note: [{"id":1,"title":"My Note","content":"Hello world","userId":0}].

  • Delete the note to confirm the full cycle:
curl -X DELETE http://localhost:3000/notes/1

You should see {"message":"Note 1 deleted"}.

Notice anything missing?

You just created, read, and deleted a note without providing any username, password, or token. Anyone who knows the URL can do whatever they want with your data. That is the problem the next steps solve.

Your CRUD API works perfectly. A little too perfectly, in fact. Next up, you will build user registration and login so the API can know who is making each request.

Add User Registration and Login

Your Notes API is live with full CRUD access. Anyone can create, read, and delete notes just by hitting the endpoints.

Before you can lock those endpoints down, you need users who can prove their identity. In this step, you will build user registration with password hashing and a login endpoint that issues signed JWT access tokens.

In this step, get ready to:
  • Generate the users and auth modules with the NestJS CLI.
  • Implement password hashing with bcryptjs during registration.
  • Issue a signed JWT token on successful login.
Generate the auth and users modules

NestJS organizes code into modules, each owning a slice of functionality. You need a UsersModule to manage user data and an AuthModule to handle authentication logic.

  • Generate the users module and service by running these commands in your terminal:
nest generate module users
nest generate service users
  • Generate the auth module, service, and controller by running:
nest generate module auth
nest generate service auth
nest generate controller auth

You should see CREATE and UPDATE lines for each file. The CLI auto-registers new modules in AppModule.

What do these commands do?

Each nest generate command scaffolds a boilerplate file and auto-registers it in the parent module. The CLI also adds UsersModule and AuthModule to your AppModule imports array so they are available application-wide.

Now you will build the UsersService that stores users in memory. The CLI generated an empty service shell. You will replace it with your implementation.

  • Open src/users/users.service.ts and replace its entire contents with the User interface and import:
import { Injectable } from '@nestjs/common';

// Shape of a user stored in memory
export interface User {
  id: number;
  username: string;
  password: string;
  role: string;
}

What does this code do?

The User interface defines the shape of each user object. It includes an id, username, password (which will store the hashed version), and a role for access control later.

  • Below the interface in the same file, add the service class:
@Injectable()
export class UsersService {
  // In-memory store for users
  private users: User[] = [];
  private idCounter = 1;

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find((user) => user.username === username);
  }

  async create(
    username: string,
    hashedPassword: string,
    role: string = 'user',
  ): Promise<Omit<User, 'password'>> {
    const user: User = {
      id: this.idCounter++,
      username,
      password: hashedPassword,
      role,
    };
    this.users.push(user);
    // Strip the password from the returned object
    const { password, ...result } = user;
    return result;
  }
}

What does this code do?

The users array acts as your in-memory database. findOne() looks up a user by username for login checks.

The create() method stores a new user and returns everything except the password field. This ensures hashed passwords never leak into API responses.

  • Save src/users/users.service.ts.

Seeing a compilation error?

Make sure the @Injectable() decorator sits directly above export class UsersService. If you accidentally placed it inside the interface block, the compiler will complain.

Help me debug my users service error

Next, update the UsersModule so other modules can use UsersService.

  • Open src/users/users.module.ts and replace its entire contents with:
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Why export the service?

Without exports: [UsersService], the service stays private to UsersModule. The AuthModule needs to inject UsersService to look up and create users, so you must export it explicitly.

  • Save src/users/users.module.ts. Check your terminal. The dev server should recompile without errors.

✔️ Awesome, I've got everything!

Great. Your UsersModule is ready to serve the auth layer.

ⓧ I'd like to double check the full code

Scroll up and confirm you have both chunks in src/users/users.service.ts in order: the import and User interface at the top, then the UsersService class below. Your src/users/users.module.ts should look like this:

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
Implement user registration with password hashing

With user storage in place, you can now build the registration flow. The AuthService will hash passwords with bcryptjs so plaintext passwords are never stored.

  • Open src/auth/auth.module.ts and replace its entire contents with:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    // Register JWT with a signing secret and 15-minute expiry
    JwtModule.register({
      global: true,
      secret: 'your-secret-key-here',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

What does this code do?

The AuthModule imports UsersModule (so AuthService can inject UsersService) and registers JwtModule with a signing secret.

The global: true option makes JwtService available anywhere in the app without re-importing JwtModule in every module. The expiresIn: '15m' means tokens become invalid after 15 minutes.

  • Save src/auth/auth.module.ts.
  • Open src/auth/auth.service.ts and replace its entire contents with the registration logic:
import {
  Injectable,
  UnauthorizedException,
  ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async register(
    username: string,
    password: string,
    role: string = 'user',
  ) {
    const existingUser = await this.usersService.findOne(username);
    if (existingUser) {
      throw new ConflictException('Username already exists');
    }
    // Hash the plaintext password before storing
    const hashedPassword = await bcrypt.hash(password, 10);
    return this.usersService.create(username, hashedPassword, role);
  }
}

What does this code do?

The register() method first checks whether the username is taken, throwing a 409 Conflict if so.

Then bcrypt.hash(password, 10) generates a salted hash with 10 rounds of processing. The original plaintext password is never stored anywhere. Only the hash gets saved.

  • Save src/auth/auth.service.ts.
  • Open src/auth/auth.controller.ts and replace its contents with the register endpoint:
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  // POST /auth/register
  @Post('register')
  async register(@Body() body: { username: string; password: string; role?: string }) {
    return this.authService.register(body.username, body.password, body.role);
  }
}

What does this code do?

The @Controller('auth') decorator prefixes all routes with /auth. The @Post('register') decorator maps POST /auth/register to this method. It extracts username and password from the request body and passes them to the service.

  • Save src/auth/auth.controller.ts. Check your terminal to confirm the app recompiles successfully.
  • Test the register endpoint by running this curl command:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d "{\"username\":\"testuser\",\"password\":\"password123\"}"

You should see a JSON response with id, username, and role fields. Notice the password is NOT included in the response.

Getting a 500 error or compilation failure?

Make sure you saved all three files: auth.module.ts, auth.service.ts, and auth.controller.ts. The dev server recompiles on save. If your terminal shows a dependency injection error, double-check that UsersModule exports UsersService and that AuthModule imports UsersModule.

Help me debug my NestJS auth registration error

Why is the password missing from the response?

The UsersService.create() method deliberately strips the password field before returning. Even though the hash is not reversible, exposing hashes in API responses is a security anti-pattern. Nothing password-related ever leaves the server.

Implement login with JWT token issuance

Users can register, but they cannot prove their identity yet. The login endpoint will verify credentials and return a signed JWT that acts as a temporary proof of identity.

  • In src/auth/auth.service.ts, find the closing brace } of the register() method and add the following signIn method below it (before the final class-closing }):
  async signIn(
    username: string,
    password: string,
  ): Promise<{ access_token: string }> {
    // Look up the user by username
    const user = await this.usersService.findOne(username);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    // Compare plaintext password against stored hash
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    // Sign a JWT with user info in the payload
    const payload = { sub: user.id, username: user.username, role: user.role };
    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }

What does this code do?

The signIn() method performs two checks. First, it confirms the user exists. Then it uses bcrypt.compare() to verify the plaintext password matches the stored hash without ever decrypting anything.

If both checks pass, it signs a JWT containing the user's id (as sub), username, and role. The same generic error message is returned for both "user not found" and "wrong password" to avoid leaking which usernames exist.

  • Save src/auth/auth.service.ts.
  • In src/auth/auth.controller.ts, find the closing brace of the register() method and add the login endpoint below it:
  // POST /auth/login
  @Post('login')
  async signIn(@Body() body: { username: string; password: string }) {
    return this.authService.signIn(body.username, body.password);
  }
  • Save src/auth/auth.controller.ts. Confirm the terminal shows successful recompilation.

Seeing a TypeScript error about signIn?

Make sure your new signIn method in auth.service.ts is inside the class (before the final }) and not accidentally placed after it. Also confirm you did not remove the import for UnauthorizedException at the top of the file.

Help me fix my signIn method TypeScript error

Now verify your app.module.ts has the correct imports. The CLI should have updated it when you ran the generate commands.

  • Open src/app.module.ts and confirm it looks like this:
import { Module } from '@nestjs/common';
import { NotesModule } from './notes/notes.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [NotesModule, AuthModule, UsersModule],
})
export class AppModule {}
  • If the imports are missing or in a different order, update the file to match and save src/app.module.ts.

Before you run the final test, think about what you expect to see. You registered a user earlier. What should the login endpoint return if the credentials are correct?

  • Test the login endpoint by running:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d "{\"username\":\"testuser\",\"password\":\"password123\"}"

You should see a JSON response with an access_token field containing a long encoded string. That is your signed JWT.

What if login returns 401?

The in-memory store resets every time the dev server restarts. If you restarted recently, your registered user is gone. Register again first with the curl command from the previous substep, then log in.

  • Copy the token value and paste it into the Encoded field at jwt.io.
  • In the decoded Payload section, confirm you see sub (the user ID), username, and role fields.

What is inside a JWT?

A JWT has three dot-separated parts: header, payload, and signature. The payload carries your custom claims (sub, username, role) plus auto-added claims like iat (issued at) and exp (expiration).

The signature ensures nobody tampers with the payload in transit. If any byte changes, the signature check fails and the token is rejected.

Getting 401 Unauthorized on login?

The in-memory store resets every time the dev server restarts. If you restarted recently, your registered user is gone. Register again first, then log in.

Help me troubleshoot my login returning 401

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is src/auth/auth.controller.ts:

import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('register')
  async register(@Body() body: { username: string; password: string; role?: string }) {
    return this.authService.register(body.username, body.password, body.role);
  }

  @Post('login')
  async signIn(@Body() body: { username: string; password: string }) {
    return this.authService.signIn(body.username, body.password);
  }
}

Here is src/auth/auth.module.ts:

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: 'your-secret-key-here',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

For src/auth/auth.service.ts, scroll up and confirm you have the imports at the top, the constructor with usersService and jwtService, the register() method, and the signIn() method in that order.

Here is src/app.module.ts:

import { Module } from '@nestjs/common';
import { NotesModule } from './notes/notes.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [NotesModule, AuthModule, UsersModule],
})
export class AppModule {}

Your API now has user registration and login. Anyone can sign up and get a token. Next up, you will build a custom AuthGuard that checks that token on every request, locking down your notes endpoints so only authenticated users can access them.

Build the AuthGuard and Lock Down Routes

Right now, your notes API is completely open. Anyone with the URL can create, read, and delete notes without proving who they are.

In this step, you'll build the core security layer: a custom AuthGuard that intercepts every incoming request, checks for a valid JWT, and rejects anything that doesn't pass. You'll register it globally so every route in your API is protected by default.

In this step, get ready to:
  • Create a custom AuthGuard that extracts and verifies JWT tokens from the Authorization header.
  • Register the guard globally so every route requires authentication.
  • Observe the chicken-and-egg problem when login itself gets locked out.
Create the AuthGuard class

A guard in NestJS is a class that decides whether a request should proceed to the route handler. Your AuthGuard will look at the Authorization header, extract the Bearer token, and verify it using JwtService.

  • Create a new file at src/auth/auth.guard.ts and add the following code:
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

What does this code do?

This guard implements the CanActivate interface, which means NestJS will call canActivate() before every request reaches its route handler.

  • extractTokenFromHeader() splits the Authorization: Bearer <token> header and returns just the token string.
  • If no token is found, the guard throws UnauthorizedException which sends back a 401 response.
  • The Reflector is injected now but used later for reading route metadata.
  • Save src/auth/auth.guard.ts.
  • Check your terminal. The dev server should recompile without errors.

Seeing a compilation error?

  • Make sure the file is saved at src/auth/auth.guard.ts (inside the auth folder, not the project root).
  • Check that Request is imported from express, not from another package.

Help me debug my AuthGuard compilation error.

The guard checks for a token, but it doesn't verify the token is legitimate yet. A request with any string in the header would pass through.

  • In src/auth/auth.guard.ts, find the canActivate method. Find this line:
    if (!token) {
      throw new UnauthorizedException();
    }
    return true;
  • Replace return true; with the JWT verification logic, so the section from if (!token) to the end of the method looks like this:
    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      // Verify the token signature and expiry
      const payload = await this.jwtService.verifyAsync(token, {
        secret: 'your-secret-key-here',
      });
      // Attach decoded user data to the request object
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;

What does the verification add?

  • jwtService.verifyAsync() checks that the token was signed with the same secret and hasn't expired. If either check fails, it throws an error.
  • request['user'] = payload attaches the decoded token data (user ID, username, role) to the request so route handlers can access it downstream.
  • The catch block handles expired tokens, tampered tokens, or tokens signed with a different secret. All are rejected with a 401.
  • Save src/auth/auth.guard.ts.
  • Confirm the dev server recompiles without errors in your terminal.

Dev server showing an error after the update?

  • Make sure the try/catch block is inside the canActivate method, after the if (!token) check.
  • Check that return true; appears after the catch block, not inside it.

Help me fix my canActivate verification code.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/auth/auth.guard.ts and your dev server is running clean.

ⓧ I'd like to double check the full code

Your src/auth/auth.guard.ts should contain both pieces you built above, in this order:

  • Imports from @nestjs/common, @nestjs/jwt, @nestjs/core, and express.
  • The AuthGuard class with JwtService and Reflector in the constructor.
  • canActivate() that extracts the token, throws if missing, verifies with jwtService.verifyAsync(), and attaches payload to request['user'].
  • extractTokenFromHeader() that splits the Authorization header and returns the Bearer token.
Register the guard globally

The guard exists, but NestJS doesn't know to use it yet. You need to register it as a global guard using APP_GUARD so it runs on every request automatically.

  • Open src/auth/auth.module.ts and replace the entire file with this updated version:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: 'your-secret-key-here',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [
    AuthService,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

What changed here?

  • APP_GUARD is a special token from @nestjs/core that tells NestJS to apply a guard to every route in the entire application.
  • useClass: AuthGuard tells the dependency injection system to instantiate your guard and use it globally.
  • The providers array now includes both AuthService (for business logic) and the global guard registration.
  • Save src/auth/auth.module.ts.
  • Check your terminal. The dev server should recompile successfully.

Seeing a dependency error on restart?

  • Make sure AuthGuard is imported from './auth.guard' (relative path in the same folder).
  • Check that APP_GUARD is imported from '@nestjs/core', not '@nestjs/common'.

Help me fix my global guard registration.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/auth/auth.module.ts and the dev server compiled without issues.

ⓧ I'd like to double check the full code

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: 'your-secret-key-here',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [
    AuthService,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}
Test the lockdown and observe the problem

Your guard is live. Every single request to your API now passes through AuthGuard.canActivate() before reaching any route handler.

  • Open a new terminal (keep the dev server running in the original one).
  • Test the notes endpoint without a token by running:

Before you run this, what do you expect to happen? The guard should reject the request because there's no Authorization header.

curl http://localhost:3000/notes

You should see this response:

{"message":"Unauthorized","statusCode":401}

The guard works. No token means no access.

  • Now try to log in. Before you run this, do you think it will succeed or fail?
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d "{\"username\":\"testuser\",\"password\":\"password123\"}"

You get the same 401 Unauthorized response. The login endpoint is also blocked.

What just happened?

You've hit a chicken-and-egg problem. The global guard protects every route, including the routes you need to get a token in the first place. You need to be logged in to log in.

This is an intentional design moment. The global guard is doing its job perfectly. The problem is that some routes (login, register) should be exempt. That's exactly what the next step solves with a @Public() decorator.

You've built a guard that locks down your entire API. The problem is clear: you've locked yourself out of your own front door. Next up, you'll create a @Public() decorator to let specific routes bypass the guard, and then add role-based access control on top.

Add Public Routes and Role-Based Access

In the previous step, you locked down your entire API with a global AuthGuard. The problem is that it locked down everything. You cannot log in because the login route itself demands a token you do not yet have.

This step introduces two solutions. First, a @Public() decorator that tells the guard to skip token validation for specific routes. Second, a @Roles() decorator and RolesGuard that restricts certain actions to admin users only.

In this step, get ready to:
  • Create a @Public() decorator and update the AuthGuard to respect it.
  • Create a @Roles() decorator and RolesGuard for role-based access control.
  • Lock the delete endpoint to admin users and verify the full authentication flow.
Create the @Public() decorator and update the AuthGuard

NestJS uses metadata reflection to pass information between decorators and guards. You will create a custom decorator that attaches a flag to a route, then teach the guard to check for that flag before demanding a token.

  • Create a new folder and file at src/auth/decorators/public.decorator.ts and add the following code:
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

What does this code do?

SetMetadata attaches a key-value pair to a route handler's metadata. When you place @Public() above a route, it stamps that route with isPublic: true. The guard can then read this stamp and decide whether to enforce authentication.

  • Save public.decorator.ts.

Now update the AuthGuard to read that metadata. Open src/auth/auth.guard.ts and make two changes.

  • At the top of the file, find this line:
import { Request } from 'express';
  • Add this import directly below it:
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
  • Next, find the first line inside the canActivate method:
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
  • Replace const request = context.switchToHttp().getRequest(); with the following block, so the public-route check runs before any token extraction:
    // Check if the route is marked as public
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();

What does this code do?

reflector.getAllAndOverride reads the isPublic metadata from both the handler method and the controller class. If either has it set to true, the guard returns true immediately and skips all token logic.

  • Save auth.guard.ts.
  • Check your terminal. The dev server should recompile without errors.

Seeing a compile error?

Make sure the decorators folder is inside src/auth/. The import path './decorators/public.decorator' is relative to auth.guard.ts.

Help me fix my import path for the Public decorator

The guard now knows how to skip public routes. But no routes are marked public yet. Time to apply the decorator to your auth endpoints.

  • Open src/auth/auth.controller.ts and replace the entire file with this updated version:
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Public()
  @Post('register')
  async register(@Body() body: { username: string; password: string; role?: string }) {
    return this.authService.register(body.username, body.password, body.role);
  }

  @Public()
  @Post('login')
  async signIn(@Body() body: { username: string; password: string }) {
    return this.authService.signIn(body.username, body.password);
  }
}

What changed?

Two changes: the Public decorator is imported at the top, and @Public() is placed above both @Post('register') and @Post('login'). These two routes now skip token validation entirely.

  • Save auth.controller.ts.

Before you run the next command, predict: will the login endpoint return a token now, or will you still see a 401?

  • First, register a new user by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d "{\"username\":\"alice\",\"password\":\"password123\"}"
  • Then log in by running:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d "{\"username\":\"alice\",\"password\":\"password123\"}"

You should see a JSON response with an access_token field. The chicken-and-egg problem is solved. Public routes bypass the guard while everything else remains locked.

Still seeing 401 on login?

Make sure you placed @Public() above @Post('login') in the controller. Decorator order matters. Also confirm the guard imports IS_PUBLIC_KEY from the correct path.

Help me debug why my @Public() decorator isn't working

Create the @Roles() decorator and RolesGuard

Authentication answers "who are you?" but authorization answers "what are you allowed to do?" You will now build a second guard that checks whether the authenticated user has the required role for a given route.

  • Create a new file at src/auth/decorators/roles.decorator.ts and add the following code:
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

What does this code do?

This follows the same pattern as @Public(). The Roles decorator stamps a route with a list of allowed roles (e.g. 'admin'). The spread operator ...roles lets you pass one or more roles: @Roles('admin', 'moderator').

  • Save roles.decorator.ts.
  • Now create the guard that reads the roles metadata. Create a new file at src/auth/guards/roles.guard.ts and add the following code:
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // Read the required roles from the route metadata
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }

    // Compare the user's role against the required roles
    const { user } = context.switchToHttp().getRequest();
    if (!user) {
      throw new ForbiddenException();
    }

    const hasRole = requiredRoles.includes(user.role);
    if (!hasRole) {
      throw new ForbiddenException('Insufficient permissions');
    }
    return true;
  }
}

What does this code do?

The RolesGuard runs after the AuthGuard. By the time it executes, request['user'] already contains the decoded JWT payload (including the user's role).

If a route has no @Roles() decorator, requiredRoles is undefined and the guard allows access. If roles are specified and the user does not have one of them, it throws a ForbiddenException (HTTP 403).

  • Save roles.guard.ts.

The guard exists but NestJS does not know about it yet. You need to register it as a global guard just like the AuthGuard.

  • Open src/auth/auth.module.ts and replace the entire file with this updated version:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: 'your-secret-key-here',
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [
    AuthService,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

Why two APP_GUARD providers?

NestJS allows multiple global guards registered with APP_GUARD. They execute in registration order. The AuthGuard runs first (verifies the token and attaches the user), then the RolesGuard runs second (checks the user's role). This layered approach keeps each guard focused on a single responsibility.

  • Save auth.module.ts.
  • Check your terminal. The dev server should recompile successfully with no errors.

Compile error mentioning RolesGuard?

Verify that the guards folder is inside src/auth/ and the import in auth.module.ts reads './guards/roles.guard' (not './decorators/roles.guard').

Help me fix my RolesGuard registration

Apply role-based protection to the notes controller

The decorators and guards are ready. Now you will apply them to the notes endpoints to enforce two rules: only admins can delete notes, and every note gets stamped with the ID of the user who created it.

  • Open src/notes/notes.controller.ts and replace the entire file with the following:
import {
  Controller,
  Get,
  Post,
  Delete,
  Body,
  Param,
  Request,
  ParseIntPipe,
} from '@nestjs/common';
import { NotesService } from './notes.service';
import { Roles } from '../auth/decorators/roles.decorator';
@Controller('notes')
export class NotesController {
  constructor(private notesService: NotesService) {}

  @Get()
  findAll() {
    return this.notesService.findAll();
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.notesService.findOne(id);
  }

  @Post()
  create(
    @Body() body: { title: string; content: string },
    @Request() req,
  ) {
    // Attach the authenticated user's ID to the note
    return this.notesService.create(body.title, body.content, req.user.sub);
  }

  @Roles('admin')
  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    this.notesService.remove(id);
    return { message: `Note ${id} deleted` };
  }
}

What changed from the previous version?

  • Request is now imported from @nestjs/common and used in the create method to read req.user.sub (the authenticated user's ID from the JWT).
  • Roles is imported from the decorator you just created.
  • @Roles('admin') above the DELETE endpoint restricts deletion to admin users only.
  • Save notes.controller.ts.

Time to test the full authentication and authorization flow. Think about what you expect to happen: can a regular user delete a note? What about an admin?

  • Register an admin user by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d "{\"username\":\"admin1\",\"password\":\"adminpass\",\"role\":\"admin\"}"
  • Log in as the regular user (alice, from earlier) and save the token:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d "{\"username\":\"alice\",\"password\":\"password123\"}"
  • Copy the access_token value from the response.
  • Create a note as alice by running (replace the token with your actual value):
curl -X POST http://localhost:3000/notes -H "Content-Type: application/json" -H "Authorization: Bearer your-access-token-here" -d "{\"title\":\"Alice Note\",\"content\":\"Hello from alice\"}"

You should see the note returned with userId set to alice's ID (pulled from the JWT, not hardcoded).

  • Now try to delete the note as alice (regular user):
curl -X DELETE http://localhost:3000/notes/1 -H "Authorization: Bearer your-access-token-here"

You should see a 403 Forbidden response with "Insufficient permissions". The RolesGuard blocked alice because she has the user role, not admin.

  • Log in as the admin user by running:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d "{\"username\":\"admin1\",\"password\":\"adminpass\"}"
  • Copy the admin's access_token and use it to delete the note:
curl -X DELETE http://localhost:3000/notes/1 -H "Authorization: Bearer your-admin-access-token-here"

You should see {"message":"Note 1 deleted"}. The admin user passes the RolesGuard and deletes successfully.

Getting 401 instead of 403?

A 401 means the token is invalid or expired (the AuthGuard rejected it). A 403 means the token is valid but the user lacks the required role (the RolesGuard rejected it). If you see 401, your token may have expired (tokens last 15 minutes). Log in again to get a fresh token.

Help me figure out why I'm getting 401 instead of 403

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here are the key files you created or modified in this step. Scroll up and confirm each chunk is present in your code.

  • src/auth/decorators/public.decorator.ts (the IS_PUBLIC_KEY constant and Public decorator).
  • src/auth/decorators/roles.decorator.ts (the ROLES_KEY constant and Roles decorator).
  • src/auth/guards/roles.guard.ts (the full RolesGuard class).
  • src/auth/auth.guard.ts (added IS_PUBLIC_KEY import and the isPublic check at the top of canActivate).
  • src/auth/auth.controller.ts (added Public import and @Public() on both routes).
  • src/auth/auth.module.ts (added RolesGuard import and second APP_GUARD provider).
  • src/notes/notes.controller.ts (added Request, Roles import, @Roles('admin') on delete, and req.user.sub in create).

Your API now has a complete authentication and authorization stack. Public routes bypass the guard. Authenticated routes require a valid JWT. Role-protected routes demand a specific role. In the Secret Mission, you will tackle the next real-world challenge: what happens when access tokens expire.

Secret mission

Implement Refresh Tokens

Access tokens expire after 15 minutes. Can you implement refresh token rotation so users stay logged in without re-entering credentials, while detecting token theft?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The notes-auth-api project folder on your Desktop.
  • The @nestjs/cli package installed globally.

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the project further.

  • Your notes-auth-api folder stays on your Desktop, ready to open and run anytime.
  • The @nestjs/cli stays installed globally for future NestJS projects.
  • To start the server again later, open a terminal in the project folder and run npm run start:dev.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal. Your project files stay intact for next time.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project files remain in the notes-auth-api folder on your Desktop.

Delete - I don't want to use this again

Remove all project files and the globally installed CLI to start completely fresh.

  • Stop the dev server by pressing Ctrl+C in the terminal where it is running.
  • Delete the project folder by running this command:
cd ~/Desktop
rm -rf notes-auth-api

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q notes-auth-api
  • Uninstall the NestJS CLI globally by running:
npm uninstall -g @nestjs/cli
  • Confirm removal by running ls ~/Desktop (macOS/Linux) or checking your Desktop in File Explorer (Windows). The notes-auth-api folder should no longer appear.

Nice Work!

Nice Work!

Nice work! You just built a fully authenticated Notes API in NestJS, writing every line of the auth stack yourself.

You've learned how to:

  • Write a custom AuthGuard from scratch that extracts a JWT from the Authorization header, verifies it, and attaches the decoded payload to the request.
  • Implement user registration with bcrypt password hashing and a login flow that issues signed access tokens.
  • Build role-based access control using a custom @Roles() decorator and RolesGuard to protect admin-only routes from regular users.
  • Secret Mission: Added refresh token rotation with reuse detection to keep sessions secure without forcing constant re-login.

Ready to quiz yourself?