Build a Multi-Tenant SaaS API

Build a NestJS REST API with JWT auth, RBAC, and PostgreSQL in Docker.

Introduction

30 Second Summary

Every app you use daily, whether it's Slack, Notion, or Jira, manages multiple teams and organizations behind the scenes. This is called multi-tenancy, and it's one of the most in-demand backend patterns in the industry.

In this project, you will build a multi-tenant workspace API from scratch. Users can register, log in, create workspaces, and manage members with different permission levels.

What You'll Build

A REST API built with NestJS and PostgreSQL that handles user authentication, workspace management, and role-based access control, all running in Docker containers.

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

  • A NestJS application with modular architecture split into auth, users, and workspaces modules.
  • A complete JWT authentication system with access tokens, refresh tokens, and password hashing.
  • A role-based access control system that enforces owner, admin, and member permissions on workspace operations.
  • Secret Mission: Add a workspace invitation system with secure invite codes and expiration logic.

Are there any prerequisites?

This project assumes you're familiar with Node.js and Express (MERN stack experience is ideal), have a basic understanding of REST APIs, and are comfortable with the command line. You don't need prior NestJS or TypeORM experience.

Set Up NestJS and Docker Environment

Every multi-tenant SaaS API needs two things before you write any business logic: a structured application framework and a reliable database. In this step, you will scaffold a NestJS project, spin up PostgreSQL inside a Docker container, and wire the two together.

By the end of this step your app will boot, connect to a real database, and be ready for you to start building entities in Step 2.

In this step, get ready to:
  • Scaffold a NestJS project with strict TypeScript.
  • Run PostgreSQL in a Docker container using Docker Compose.
  • Connect your NestJS app to the database with TypeORM.
Scaffold the NestJS project

The NestJS CLI generates a full project structure with modules, controllers, services, and TypeScript configuration out of the box. The --strict flag enables stricter TypeScript compiler options, catching more bugs at compile time.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new NestJS project by running this command:
nest new workspace-api --strict

What will this command ask me?

The CLI will ask you which package manager to use. Select npm when prompted. The CLI then installs all dependencies automatically.

  • Once installation finishes, move into the new project folder by running:
cd workspace-api
  • Verify the project structure by running:
ls

You should see folders like src/ and test/, plus files like nest-cli.json and package.json.

Command not found?

If you see an error when running nest new, the NestJS CLI may not be on your PATH. Close and reopen your terminal, then try again.

help me troubleshoot why the nest command is not found

  • Open Cursor by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Select the workspace-api folder on your Desktop, then click Open.
Create the Docker Compose configuration

Docker Compose lets you define and run multi-container applications with a single YAML file. You will use it to run a PostgreSQL database that your NestJS app connects to.

  • In Cursor's Explorer sidebar, right-click on the workspace-api root folder and select New File.
  • Name the file .env and press Enter.
  • Paste the following environment variables into .env:
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=workspace_db
DB_HOST=localhost
DB_PORT=5432
APP_PORT=3000

What do these variables do?

These key-value pairs configure your database connection. Docker Compose and NestJS both read from this file so your credentials stay in one place.

  • DB_USER and DB_PASSWORD are the Postgres login credentials.
  • DB_NAME is the database that Postgres creates on first boot.
  • DB_HOST is localhost because Postgres runs on your machine via Docker.
  • Save .env.
  • Create another new file in the project root called docker-compose.yml.
  • Paste the following configuration into docker-compose.yml:
version: '3.8'
services:
  postgres:
    image: postgres:16
    container_name: postgres
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  postgres_data:

What does this Compose file do?

This file defines a single Postgres service that Docker manages for you.

  • image: postgres:16 pulls the official PostgreSQL 16 image.
  • The environment block passes your .env values to Postgres so it creates the database with the right credentials.
  • The volumes section persists data across container restarts using a named volume.
  • The healthcheck runs pg_isready every 10 seconds to confirm Postgres is accepting connections.
  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Make sure both .env and docker-compose.yml are saved in the root of your workspace-api folder.

ⓧ I'd like to double check the full code

Here is the complete .env file:

DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=workspace_db
DB_HOST=localhost
DB_PORT=5432
APP_PORT=3000

And here is the complete docker-compose.yml:

version: '3.8'
services:
  postgres:
    image: postgres:16
    container_name: postgres
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  postgres_data:
  • Open Cursor's integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Start the Postgres container by running this command:
docker compose up -d
  • Verify PostgreSQL is running and accepting connections by running:
docker exec -it postgres psql -U postgres -d workspace_db -c "SELECT 1"

You should see a table with a single column header and the value 1 underneath it. This confirms Postgres is healthy and your database exists.

Connection refused or container not running?

If the docker exec command fails, check that Docker Desktop is running. Then run docker compose ps to see if the container started.

If the container shows as unhealthy, check your .env file has no typos in DB_USER or DB_NAME. You can also run docker compose logs postgres to see error details.

help me troubleshoot why my Docker Postgres container is not starting

Install TypeORM and configure the database connection

TypeORM is an Object-Relational Mapper that lets you define database tables as TypeScript classes. NestJS has a first-party integration package that handles connection pooling and module registration.

  • Install the required packages by running:
npm i --save @nestjs/typeorm typeorm pg @nestjs/config

What are these packages?

  • @nestjs/typeorm is the NestJS integration module for TypeORM.
  • typeorm is the ORM library itself.
  • pg is the PostgreSQL driver that TypeORM uses under the hood.
  • @nestjs/config reads your .env file and makes values available via dependency injection.

Install failing with permission errors?

If you see EACCES errors, avoid using sudo. Instead, check that your terminal is inside the workspace-api folder (not the Desktop) so npm writes to the project's local node_modules.

help me fix npm install permission errors in my NestJS project

  • Open src/app.module.ts in the Explorer sidebar.
  • Replace the entire file contents with the following code:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    // Load .env file and make values available app-wide
    ConfigModule.forRoot({ isGlobal: true }),
    // Connect to Postgres using environment variables
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

What does this configuration do?

  • ConfigModule.forRoot({ isGlobal: true }) loads your .env file and makes the values injectable in any module without re-importing.
  • TypeOrmModule.forRootAsync sets up the database connection asynchronously so it can read from ConfigService after the config module loads.
  • The entities glob pattern auto-discovers any file ending in .entity.ts or .entity.js so you never have to manually register entities.
  • synchronize: true automatically creates or updates database tables to match your entities. This is convenient for development but should be disabled in production.
  • Save src/app.module.ts.
  • Verify the app connects to Postgres by running:
npm run start:dev

You should see NestJS log its startup sequence with no connection errors. If TypeORM cannot reach the database, you would see a retry message instead.

Seeing connection refused or retry errors?

If you see a message about being unable to connect to the database, check that your Docker container is still running with docker compose ps.

Also confirm that DB_HOST in your .env is set to localhost and DB_PORT is 5432. These must match what Docker Compose exposes.

help me debug why NestJS cannot connect to my PostgreSQL Docker container

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/app.module.ts and the app is running without errors.

ⓧ I'd like to double check the full code

Here is the complete src/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}

Your development environment is fully wired up. Next up, you will design the database schema for users, workspaces, and memberships.

Design the Database Schema

Your NestJS app is running and connected to PostgreSQL. Now you need to define what data your multi-tenant workspace API actually stores.

Every SaaS application needs a data model that captures users, resources, and the relationships between them. In this step, you will design three TypeORM entities (User, Workspace, WorkspaceMember) and wire them into NestJS modules with services and controllers that expose REST endpoints.

In this step, get ready to:
  • Create a User entity and UsersModule with service and controller.
  • Create Workspace and WorkspaceMember entities with relational mappings.
  • Build a WorkspacesModule with CRUD endpoints that auto-assign the creator as owner.
Create the User entity and UsersModule

In TypeORM, an entity is a TypeScript class decorated with column metadata. When synchronize: true is set, TypeORM reads your entity classes and automatically creates (or updates) the matching database tables.

  • Create a new folder src/users inside your workspace-api project.
  • Inside src/users, create a file called user.entity.ts and add the following code:
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity()
export class User {
  // Auto-generate a UUID for each new user
  @PrimaryGeneratedColumn('uuid')
  id: string;

  // Email must be unique across all users
  @Column({ unique: true })
  email: string;

  // Stores the bcrypt hash, never the raw password
  @Column()
  passwordHash: string;

  @Column()
  displayName: string;

  // TypeORM auto-sets these timestamps
  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

What does this code do?

Each @Column() decorator maps a class property to a database column. @PrimaryGeneratedColumn('uuid') tells PostgreSQL to auto-generate a UUID primary key for every new row.

The @CreateDateColumn() and @UpdateDateColumn() decorators make TypeORM handle timestamp management automatically. You never set these manually.

  • Save user.entity.ts.
  • Check that your NestJS dev server (npm run start:dev) restarts without errors. Because app.module.ts uses the entity glob pattern __dirname + '/**/*.entity{.ts,.js}', TypeORM will detect the new entity and synchronize the database.
  • Verify the table was created by running:
docker exec -it postgres psql -U postgres -d workspace_db -c "\dt"
  • You should see a user table listed in the output.

Don't see the user table?

  • Make sure your Docker Postgres container is still running. Run docker compose up -d if it stopped.
  • Check that npm run start:dev is running without errors. If TypeORM cannot connect, the table will not be created.
  • Confirm the file is named exactly user.entity.ts (not user.entity.tsx or User.entity.ts). The glob pattern is case-sensitive.

Why isn't TypeORM creating my user table?

Now create the service, controller, and module to make this entity usable. These three files follow the standard NestJS pattern: the service handles data access, the controller handles HTTP, and the module wires them together.

  • Create src/users/users.service.ts with the following code:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    // Inject the TypeORM repository for the User entity
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.usersRepository.findOne({ where: { email } });
  }

  async findById(id: string): Promise<User | null> {
    return this.usersRepository.findOne({ where: { id } });
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.usersRepository.create(data);
    return this.usersRepository.save(user);
  }

  async save(user: User): Promise<User> {
    return this.usersRepository.save(user);
  }
}

What does this service do?

The @InjectRepository(User) decorator uses NestJS dependency injection to provide a TypeORM Repository instance. This repository gives you methods like findOne, create, and save to interact with the user table without writing raw SQL.

  • Create src/users/users.controller.ts with this code:
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // GET /users/:id - find a single user by their UUID
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findById(id);
  }
}
  • Create src/users/users.module.ts to wire everything together:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  // Register the User repository in this module's scope
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  // Export so other modules (e.g. AuthModule) can use UsersService
  exports: [UsersService],
})
export class UsersModule {}

What is TypeOrmModule.forFeature?

While TypeOrmModule.forRoot() in app.module.ts establishes the database connection, TypeOrmModule.forFeature([User]) registers the User repository within this module's scope. This lets you inject @InjectRepository(User) in any provider belonging to this module.

Finally, import the UsersModule into your AppModule so NestJS loads it at startup.

  • In src/app.module.ts, find the imports array and add the UsersModule import. Your file should now look like this:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
    UsersModule,
  ],
})
export class AppModule {}
  • Save all files.
  • Check that npm run start:dev restarts successfully with no errors in the terminal.

Seeing a "Cannot find module" error?

  • Double-check that all four files are in src/users/ and that the import paths use ./user.entity (not ./users.entity).
  • Make sure the UsersModule import in app.module.ts uses the path './users/users.module'.

Help me fix my NestJS module import error.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

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

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column()
  displayName: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.usersRepository.findOne({ where: { email } });
  }

  async findById(id: string): Promise<User | null> {
    return this.usersRepository.findOne({ where: { id } });
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.usersRepository.create(data);
    return this.usersRepository.save(user);
  }

  async save(user: User): Promise<User> {
    return this.usersRepository.save(user);
  }
}
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findById(id);
  }
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService],
})
export class UsersModule {}
Create the Workspace and WorkspaceMember entities

A multi-tenant system needs at least two more concepts: the workspace itself and a join table that tracks which users belong to which workspaces (and in what role).

  • Create a new folder src/workspaces inside your project.
  • Create src/workspaces/workspace.entity.ts with the following code:
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { WorkspaceMember } from './workspace-member.entity';

@Entity()
export class Workspace {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  // URL-friendly identifier, must be unique
  @Column({ unique: true })
  slug: string;

  @CreateDateColumn()
  createdAt: Date;

  // One workspace has many members
  @OneToMany(() => WorkspaceMember, (wm) => wm.workspace)
  members: WorkspaceMember[];
}

What is a slug?

A slug is a URL-friendly string (like my-team) that identifies a workspace in API paths and links. Marking it unique: true ensures no two workspaces share the same slug.

  • Create src/workspaces/workspace-member.entity.ts with the following code:
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';
import { Workspace } from './workspace.entity';

// Define the three possible roles within a workspace
export enum MemberRole {
  OWNER = 'owner',
  ADMIN = 'admin',
  MEMBER = 'member',
}

@Entity()
export class WorkspaceMember {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Column()
  workspaceId: string;

  // Store the role as a Postgres enum type
  @Column({ type: 'enum', enum: MemberRole, default: MemberRole.MEMBER })
  role: MemberRole;

  // Many members belong to one user
  @ManyToOne(() => User, (user) => user.workspaceMemberships)
  @JoinColumn({ name: 'userId' })
  user: User;

  // Many members belong to one workspace
  @ManyToOne(() => Workspace, (workspace) => workspace.members)
  @JoinColumn({ name: 'workspaceId' })
  workspace: Workspace;
}

What does this join table do?

The WorkspaceMember entity sits between User and Workspace. It stores the role each user holds in each workspace. This is a many-to-many relationship implemented as two many-to-one relationships with an extra column (role).

The @ManyToOne decorators tell TypeORM to create foreign key constraints. The @JoinColumn decorator specifies which column holds the foreign key value.

Now update user.entity.ts to add the reverse side of the relationship so TypeORM can navigate from User to WorkspaceMember.

  • In src/users/user.entity.ts, replace the entire file contents with this updated version:
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { WorkspaceMember } from '../workspaces/workspace-member.entity';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column()
  displayName: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  // One user can be a member of many workspaces
  @OneToMany(() => WorkspaceMember, (wm) => wm.user)
  workspaceMemberships: WorkspaceMember[];
}
  • Save all three files.
  • Wait for npm run start:dev to restart, then verify the new tables exist by running:
docker exec -it postgres psql -U postgres -d workspace_db -c "\dt"
  • You should now see three tables: user, workspace, and workspace_member.

Only seeing the user table?

  • Check the terminal for TypeScript compilation errors. If there is a circular import issue, the app may fail silently.
  • Verify that workspace.entity.ts imports from './workspace-member.entity' and workspace-member.entity.ts imports from './workspace.entity'. Both files must exist before the app restarts.

Why aren't my workspace tables being created?

✔️ Awesome, I've got everything!

Great. All three entities are defined and synchronized to the database.

ⓧ I'd like to double check the full code

Here is the complete code for all entity files:

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { WorkspaceMember } from '../workspaces/workspace-member.entity';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column()
  displayName: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => WorkspaceMember, (wm) => wm.user)
  workspaceMemberships: WorkspaceMember[];
}
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
import { WorkspaceMember } from './workspace-member.entity';

@Entity()
export class Workspace {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ unique: true })
  slug: string;

  @CreateDateColumn()
  createdAt: Date;

  @OneToMany(() => WorkspaceMember, (wm) => wm.workspace)
  members: WorkspaceMember[];
}
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../users/user.entity';
import { Workspace } from './workspace.entity';

export enum MemberRole {
  OWNER = 'owner',
  ADMIN = 'admin',
  MEMBER = 'member',
}

@Entity()
export class WorkspaceMember {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Column()
  workspaceId: string;

  @Column({ type: 'enum', enum: MemberRole, default: MemberRole.MEMBER })
  role: MemberRole;

  @ManyToOne(() => User, (user) => user.workspaceMemberships)
  @JoinColumn({ name: 'userId' })
  user: User;

  @ManyToOne(() => Workspace, (workspace) => workspace.members)
  @JoinColumn({ name: 'workspaceId' })
  workspace: Workspace;
}
Build the WorkspacesModule with CRUD endpoints

With entities defined, you need a module that exposes HTTP endpoints for creating and querying workspaces. The key behavior: when a workspace is created, the creator is automatically added as the owner in the workspace_member table.

  • Create src/workspaces/workspaces.service.ts with the following code:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember, MemberRole } from './workspace-member.entity';

@Injectable()
export class WorkspacesService {
  constructor(
    @InjectRepository(Workspace)
    private readonly workspacesRepository: Repository<Workspace>,
    @InjectRepository(WorkspaceMember)
    private readonly membersRepository: Repository<WorkspaceMember>,
  ) {}

  async create(name: string, slug: string, userId: string): Promise<Workspace> {
    // Create the workspace record
    const workspace = this.workspacesRepository.create({ name, slug });
    const saved = await this.workspacesRepository.save(workspace);
    // Auto-add the creator as the workspace owner
    const member = this.membersRepository.create({ userId, workspaceId: saved.id, role: MemberRole.OWNER });
    await this.membersRepository.save(member);
    return saved;
  }

  async findByUser(userId: string): Promise<Workspace[]> {
    // Find all memberships for this user, then extract the workspace
    const memberships = await this.membersRepository.find({ where: { userId }, relations: ['workspace'] });
    return memberships.map((m) => m.workspace);
  }

  async findById(id: string): Promise<Workspace | null> {
    return this.workspacesRepository.findOne({ where: { id } });
  }
}

What does the create method do?

The create method performs two database operations: it saves the new workspace, then immediately creates a WorkspaceMember record linking the creator to that workspace with the OWNER role. This guarantees every workspace always has at least one owner.

  • Create src/workspaces/workspaces.controller.ts with this code:
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';

@Controller('workspaces')
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  // POST /workspaces - create a new workspace
  @Post()
  create(@Body() body: { name: string; slug: string; userId: string }) {
    return this.workspacesService.create(body.name, body.slug, body.userId);
  }

  // GET /workspaces - list all workspaces for a user
  @Get()
  findAll(@Body() body: { userId: string }) {
    return this.workspacesService.findByUser(body.userId);
  }

  // GET /workspaces/:id - get a single workspace by ID
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }
}
  • Create src/workspaces/workspaces.module.ts to wire up the workspace entities, service, and controller:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember } from './workspace-member.entity';
import { WorkspacesService } from './workspaces.service';
import { WorkspacesController } from './workspaces.controller';

@Module({
  // Register both workspace-related entities
  imports: [TypeOrmModule.forFeature([Workspace, WorkspaceMember])],
  providers: [WorkspacesService],
  controllers: [WorkspacesController],
  exports: [WorkspacesService],
})
export class WorkspacesModule {}

Finally, register the WorkspacesModule in app.module.ts.

  • In src/app.module.ts, find the line that says:
import { UsersModule } from './users/users.module';
  • Add the WorkspacesModule import directly below it, and add WorkspacesModule to the imports array, so the full file looks like this:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { WorkspacesModule } from './workspaces/workspaces.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
    UsersModule,
    WorkspacesModule,
  ],
})
export class AppModule {}
  • Save all files and wait for the app to restart.

Now test the workspace creation endpoint. First, insert a test user directly into PostgreSQL so you have a valid userId to work with.

  • Create a test user by running:
docker exec -it postgres psql -U postgres -d workspace_db -c "INSERT INTO \"user\" (id, email, \"passwordHash\", \"displayName\") VALUES (gen_random_uuid(), 'test@example.com', 'placeholder', 'Test User') RETURNING id;"
  • Copy the UUID printed in the output. You will use it in the next command.
  • Test the POST endpoint by running this curl command (replace the UUID placeholder with your actual value):
curl -X POST http://localhost:3000/workspaces -H "Content-Type: application/json" -d '{"name": "My Team", "slug": "my-team", "userId": "[[USER_UUID="the UUID from the previous command"]]"}'
  • You should see a JSON response containing the workspace id, name, slug, and createdAt fields.

What about the workspace member?

The response only shows workspace data, but the service also created a workspace_member row behind the scenes. You can verify this by running:

docker exec -it postgres psql -U postgres -d workspace_db -c "SELECT * FROM workspace_member;"

You should see a row with the role set to owner.

Getting a 500 error on POST?

  • If you see a foreign key violation, the userId you passed does not exist in the user table. Make sure you copied the UUID from the INSERT command correctly.
  • If you see a duplicate key error on slug, a workspace with that slug already exists. Use a different slug value.
  • If the endpoint returns 404, make sure WorkspacesModule is imported in app.module.ts and the app restarted without errors.

Help me debug my workspace creation endpoint.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete code for all workspace files and the updated app.module.ts:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember, MemberRole } from './workspace-member.entity';

@Injectable()
export class WorkspacesService {
  constructor(
    @InjectRepository(Workspace)
    private readonly workspacesRepository: Repository<Workspace>,
    @InjectRepository(WorkspaceMember)
    private readonly membersRepository: Repository<WorkspaceMember>,
  ) {}

  async create(name: string, slug: string, userId: string): Promise<Workspace> {
    const workspace = this.workspacesRepository.create({ name, slug });
    const saved = await this.workspacesRepository.save(workspace);
    const member = this.membersRepository.create({ userId, workspaceId: saved.id, role: MemberRole.OWNER });
    await this.membersRepository.save(member);
    return saved;
  }

  async findByUser(userId: string): Promise<Workspace[]> {
    const memberships = await this.membersRepository.find({ where: { userId }, relations: ['workspace'] });
    return memberships.map((m) => m.workspace);
  }

  async findById(id: string): Promise<Workspace | null> {
    return this.workspacesRepository.findOne({ where: { id } });
  }
}
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';

@Controller('workspaces')
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  @Post()
  create(@Body() body: { name: string; slug: string; userId: string }) {
    return this.workspacesService.create(body.name, body.slug, body.userId);
  }

  @Get()
  findAll(@Body() body: { userId: string }) {
    return this.workspacesService.findByUser(body.userId);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember } from './workspace-member.entity';
import { WorkspacesService } from './workspaces.service';
import { WorkspacesController } from './workspaces.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Workspace, WorkspaceMember])],
  providers: [WorkspacesService],
  controllers: [WorkspacesController],
  exports: [WorkspacesService],
})
export class WorkspacesModule {}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { WorkspacesModule } from './workspaces/workspaces.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
    UsersModule,
    WorkspacesModule,
  ],
})
export class AppModule {}

Your database schema is fully designed and tested. You have Users, Workspaces, and a membership join table with role-based access baked in. Next up, you will build a proper JWT authentication system so users can register, log in, and access these endpoints securely.

Implement JWT Authentication

Your database schema is set up and your workspace endpoints are working. But right now, anyone can hit those endpoints without identifying themselves.

In this step, you will build a full authentication system using JWT (JSON Web Tokens). This gives your API the ability to verify who is making each request and reject unauthorized callers.

In this step, get ready to:
  • Create an AuthModule with registration and login endpoints that return signed JWT tokens.
  • Build a global JwtAuthGuard that protects every route by default.
  • Implement a token refresh endpoint for long-lived sessions.
Create the AuthModule with register and login

The auth system needs two packages: @nestjs/jwt for signing and verifying tokens, and bcrypt for securely hashing passwords before storing them.

  • Install the auth dependencies by running this command:
npm i @nestjs/jwt bcrypt && npm i -D @types/bcrypt

What does this install?

The @nestjs/jwt package wraps the jsonwebtoken library with NestJS-friendly decorators and dependency injection.

The bcrypt package hashes passwords so you never store plaintext credentials. The @types/bcrypt package adds TypeScript type definitions for it.

Install failing?

If bcrypt fails to compile native bindings, make sure you have build tools installed on your system (Xcode Command Line Tools on macOS, or build-essential on Linux).

help me debug a bcrypt install failure on my system

Now you need two environment variables to serve as signing secrets for your tokens. The access token and refresh token each use a different secret so that compromising one does not compromise the other.

  • Open your .env file in the workspace-api project root.
  • Add these two lines at the bottom of the file:
JWT_ACCESS_SECRET=access_secret_change_me
JWT_REFRESH_SECRET=refresh_secret_change_me

Why two separate secrets?

Access tokens are short-lived (15 minutes) and sent with every API request. Refresh tokens are long-lived (7 days) and used only to get new access tokens.

If an attacker intercepts an access token, they cannot use it to forge a refresh token because the secrets are different.

The User entity needs a new column to store a hashed version of the refresh token. This allows you to revoke tokens by clearing the hash.

  • In src/users/user.entity.ts, find the displayName column.
  • Add this new column directly below it:
  @Column({ nullable: true })
  refreshTokenHash: string | null;
  • Your displayName and refreshTokenHash columns should now sit next to each other like this:
  @Column()
  displayName: string;

  @Column({ nullable: true })
  refreshTokenHash: string | null;

What does nullable: true do?

A newly created user has no refresh token yet. The nullable: true option tells PostgreSQL that this column can be NULL, which is the default state before the user logs in.

  • Save user.entity.ts.

Now create the core auth service. This file handles password hashing, user creation, credential validation, and token generation.

  • Create a new folder called auth inside src/.
  • Create a new file src/auth/auth.service.ts and paste this code:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

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

  // Hash password and create a new user, then return tokens
  async register(email: string, password: string, displayName: string) {
    const passwordHash = await bcrypt.hash(password, 10);
    const user = await this.usersService.create({ email, passwordHash, displayName });
    return this.generateTokens(user.id, user.email);
  }

  // Validate credentials and return tokens on success
  async login(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
    if (!user) throw new UnauthorizedException('Invalid credentials');
    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) throw new UnauthorizedException('Invalid credentials');
    return this.generateTokens(user.id, user.email);
  }

  // Sign access and refresh tokens with different secrets and expiries
  private async generateTokens(userId: string, email: string) {
    const payload = { sub: userId, email };
    const accessToken = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_ACCESS_SECRET'),
      expiresIn: '15m',
    });
    const refreshToken = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_REFRESH_SECRET'),
      expiresIn: '7d',
    });
    // Store hashed refresh token for revocation support
    const user = await this.usersService.findById(userId);
    user!.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await this.usersService.save(user!);
    return { accessToken, refreshToken };
  }
}

What does this code do?

  • register() hashes the password with a salt round of 10, saves the user, and returns a token pair.
  • login() looks up the user by email, compares the plaintext password against the stored hash, and throws a 401 if either check fails.
  • generateTokens() creates two JWTs signed with different secrets. The access token expires in 15 minutes. The refresh token expires in 7 days. It also stores a bcrypt hash of the refresh token on the user record.

Next, create the module file that wires up the auth service with its dependencies.

  • Create src/auth/auth.module.ts and paste this code:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    ConfigModule,
    // Register JwtModule without default options - we pass secrets per-call
    JwtModule.register({}),
    UsersModule,
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

Why is JwtModule.register({}) empty?

Your access and refresh tokens use different secrets and expiry times. Instead of setting a default in the module registration, you pass the secret and expiresIn on each jwtService.sign() call. This gives you full control over each token type.

Finally, create the controller that exposes the register and login HTTP endpoints.

  • Create src/auth/auth.controller.ts and paste this code:
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';

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

  @Post('register')
  register(@Body() body: { email: string; password: string; displayName: string }) {
    return this.authService.register(body.email, body.password, body.displayName);
  }

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

What does this code do?

The @Controller('auth') decorator prefixes all routes with /auth. The two @Post() endpoints extract the request body and delegate to the AuthService.

You will add the @Public() decorator to these endpoints shortly. For now, the app has no global guard yet, so they are accessible without tokens.

You need to register the AuthModule in your root module before it will work.

  • In src/app.module.ts, find the imports array.
  • Add AuthModule to the imports and add its import statement at the top of the file:
import { AuthModule } from './auth/auth.module';
  • Place AuthModule after WorkspacesModule in the imports array so it reads:
    UsersModule,
    WorkspacesModule,
    AuthModule,
  • Save all files (.env, user.entity.ts, auth.service.ts, auth.module.ts, auth.controller.ts, app.module.ts).
  • Check your terminal where npm run start:dev is running. It should recompile without errors.
  • Test registration by running this command in a new terminal:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","displayName":"Test User"}'
  • You should see a JSON response containing accessToken and refreshToken fields, both long encoded strings.

Getting a 500 error on register?

Check that your PostgreSQL container is still running with docker compose ps. If the user table has not synced the new refreshTokenHash column, restart the app with npm run start:dev so TypeORM re-synchronizes.

help me debug a 500 error when calling POST /auth/register in my NestJS app

Protect routes with a global JwtAuthGuard

Registration and login work, but your workspace endpoints are still wide open. A global guard ensures that every request must carry a valid token unless the route is explicitly marked as public.

Start by creating a small decorator that marks specific routes as exempt from authentication.

  • Create src/auth/public.decorator.ts and paste this code:
import { SetMetadata } from '@nestjs/common';

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

What does SetMetadata do?

NestJS uses a metadata system to attach information to route handlers. The @Public() decorator attaches a key-value pair (isPublic: true) that the guard can read at runtime using the Reflector class to decide whether to skip token verification.

Now build the guard itself. This is the core of your auth system. It intercepts every incoming request, extracts the Bearer token, and verifies it.

  • Create src/auth/jwt-auth.guard.ts and paste this code:
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    // Check if the route is marked @Public() - skip auth if so
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    // Extract the Bearer token from the Authorization header
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers['authorization'];
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException();
    }
    const token = authHeader.split(' ')[1];
    try {
      // Verify token signature and expiry, attach payload to request
      const payload = this.jwtService.verify(token, {
        secret: this.configService.get('JWT_ACCESS_SECRET'),
      });
      request.user = payload;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}

What does this code do?

  • The guard implements the CanActivate interface, which NestJS calls before every route handler.
  • It first checks for the @Public() metadata. If the route is public, it returns true immediately.
  • For protected routes, it extracts the token from the Authorization: Bearer <token> header and verifies it using the access secret.
  • On success, it attaches the decoded payload (containing sub and email) to request.user so controllers can identify the caller.

Now wire the guard into your application. You need to update three files: the auth module (to provide the guard), the app module (to apply it globally), and the auth controller (to mark its endpoints as public).

  • In src/auth/auth.module.ts, add the JwtAuthGuard import and include it in providers and exports. Replace the full file with:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtAuthGuard } from './jwt-auth.guard';

@Module({
  imports: [
    ConfigModule,
    JwtModule.register({}),
    UsersModule,
  ],
  providers: [AuthService, JwtAuthGuard],
  controllers: [AuthController],
  exports: [AuthService, JwtAuthGuard],
})
export class AuthModule {}
  • In src/app.module.ts, you need to import APP_GUARD and JwtAuthGuard, then add a providers section that registers the guard globally. Replace the full file with:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { UsersModule } from './users/users.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
    UsersModule,
    WorkspacesModule,
    AuthModule,
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

What does APP_GUARD do?

The APP_GUARD token tells NestJS to apply this guard to every route in the application automatically. Without it, you would need to add @UseGuards(JwtAuthGuard) to every single controller. The global approach means routes are protected by default and you opt out with @Public().

  • In src/auth/auth.controller.ts, add the @Public() decorator to both endpoints so users can register and log in without an existing token. Replace the full file with:
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';

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

  @Public()
  @Post('register')
  register(@Body() body: { email: string; password: string; displayName: string }) {
    return this.authService.register(body.email, body.password, body.displayName);
  }

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

Since your workspace routes are now protected, the controller needs to read the user ID from the token payload instead of the request body.

  • In src/workspaces/workspaces.controller.ts, replace the full file with this updated version that uses req.user.sub:
import { Controller, Post, Get, Param, Body, Request } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';

@Controller('workspaces')
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  @Post()
  create(@Body() body: { name: string; slug: string }, @Request() req: any) {
    // Get the user ID from the JWT payload attached by JwtAuthGuard
    return this.workspacesService.create(body.name, body.slug, req.user.sub);
  }

  @Get()
  findAll(@Request() req: any) {
    // List only workspaces the authenticated user belongs to
    return this.workspacesService.findByUser(req.user.sub);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }
}

What changed here?

The create() method no longer accepts userId in the body. It reads req.user.sub which is the user's UUID from the decoded JWT payload. The same pattern applies to findAll(). This ensures users can only access their own workspaces.

  • Save all files and wait for the dev server to recompile.
  • Test that the guard blocks unauthenticated requests by running:
curl -X GET http://localhost:3000/workspaces
  • You should see a 401 Unauthorized response. This confirms the guard is working.
  • Now test with a valid token. First log in (use the credentials you registered earlier):
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'
  • Copy the accessToken value from the response.
  • Call the workspaces endpoint with the token:
curl -X GET http://localhost:3000/workspaces \
  -H "Authorization: Bearer your-access-token-here"
  • Replace your-access-token-here with the actual token you copied.
  • You should see a 200 OK response with an empty array (no workspaces created yet for this user).

Still getting 401 with your token?

Make sure the Authorization header starts with Bearer (with a space after it). Also check that your .env file has JWT_ACCESS_SECRET set. If the secret used to sign and verify do not match, verification fails silently.

help me debug why my JWT token is being rejected by the guard

Add the refresh token endpoint

Access tokens expire after 15 minutes. Without a refresh mechanism, users would need to log in again every quarter hour. The refresh endpoint accepts a valid refresh token and returns a fresh token pair without requiring the password again.

  • In src/auth/auth.service.ts, find the login() method.
  • Add this refresh() method directly below it (before the private async generateTokens line):
  // Verify a refresh token and issue a new token pair
  async refresh(refreshToken: string) {
    try {
      const payload = this.jwtService.verify(refreshToken, {
        secret: this.configService.get('JWT_REFRESH_SECRET'),
      });
      const user = await this.usersService.findById(payload.sub);
      if (!user || !user.refreshTokenHash) throw new UnauthorizedException();
      const valid = await bcrypt.compare(refreshToken, user.refreshTokenHash);
      if (!valid) throw new UnauthorizedException();
      return this.generateTokens(user.id, user.email);
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

What does this code do?

The method verifies the refresh token's signature using the refresh secret. It then looks up the user and compares the incoming token against the stored hash. If anything fails (expired, tampered, revoked), it throws a 401.

On success, it calls generateTokens() which issues a new pair and updates the stored hash. This is called token rotation. Each refresh token is single-use because the hash changes on every refresh.

  • In src/auth/auth.controller.ts, add the refresh endpoint. Replace the full file with:
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';

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

  @Public()
  @Post('register')
  register(@Body() body: { email: string; password: string; displayName: string }) {
    return this.authService.register(body.email, body.password, body.displayName);
  }

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

  @Public()
  @Post('refresh')
  refresh(@Body() body: { refreshToken: string }) {
    return this.authService.refresh(body.refreshToken);
  }
}
  • Save both files and wait for the dev server to recompile.
  • Test the refresh endpoint. First log in to get a fresh token pair:
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'
  • Copy the refreshToken value from the response.
  • Call the refresh endpoint with it:
curl -X POST http://localhost:3000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"your-refresh-token-here"}'
  • Replace your-refresh-token-here with the actual refresh token you copied.
  • You should see a new accessToken and refreshToken pair in the response.

Getting 'Invalid refresh token'?

If you previously called refresh with the same token, it will not work a second time. Each refresh rotates the stored hash, so the old token becomes invalid. Log in again to get a fresh refresh token.

help me debug why my refresh token is being rejected

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the src/auth/ folder, src/app.module.ts, src/workspaces/workspaces.controller.ts, and your .env file.

ⓧ I'd like to double check the full code

Here is the full code for each file at the end of this step.

DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=workspace_db
DB_HOST=localhost
DB_PORT=5432
APP_PORT=3000
JWT_ACCESS_SECRET=access_secret_change_me
JWT_REFRESH_SECRET=refresh_secret_change_me
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { UsersModule } from './users/users.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('DB_HOST'),
        port: +configService.get<number>('DB_PORT'),
        username: configService.get('DB_USER'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
      inject: [ConfigService],
    }),
    UsersModule,
    WorkspacesModule,
    AuthModule,
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { WorkspaceMember } from '../workspaces/workspace-member.entity';

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  passwordHash: string;

  @Column()
  displayName: string;

  @Column({ nullable: true })
  refreshTokenHash: string | null;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => WorkspaceMember, (wm) => wm.user)
  workspaceMemberships: WorkspaceMember[];
}
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtAuthGuard } from './jwt-auth.guard';

@Module({
  imports: [
    ConfigModule,
    JwtModule.register({}),
    UsersModule,
  ],
  providers: [AuthService, JwtAuthGuard],
  controllers: [AuthController],
  exports: [AuthService, JwtAuthGuard],
})
export class AuthModule {}
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

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

  async register(email: string, password: string, displayName: string) {
    const passwordHash = await bcrypt.hash(password, 10);
    const user = await this.usersService.create({ email, passwordHash, displayName });
    return this.generateTokens(user.id, user.email);
  }

  async login(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
    if (!user) throw new UnauthorizedException('Invalid credentials');
    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) throw new UnauthorizedException('Invalid credentials');
    return this.generateTokens(user.id, user.email);
  }

  async refresh(refreshToken: string) {
    try {
      const payload = this.jwtService.verify(refreshToken, {
        secret: this.configService.get('JWT_REFRESH_SECRET'),
      });
      const user = await this.usersService.findById(payload.sub);
      if (!user || !user.refreshTokenHash) throw new UnauthorizedException();
      const valid = await bcrypt.compare(refreshToken, user.refreshTokenHash);
      if (!valid) throw new UnauthorizedException();
      return this.generateTokens(user.id, user.email);
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

  private async generateTokens(userId: string, email: string) {
    const payload = { sub: userId, email };
    const accessToken = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_ACCESS_SECRET'),
      expiresIn: '15m',
    });
    const refreshToken = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_REFRESH_SECRET'),
      expiresIn: '7d',
    });
    const user = await this.usersService.findById(userId);
    user!.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await this.usersService.save(user!);
    return { accessToken, refreshToken };
  }
}
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';

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

  @Public()
  @Post('register')
  register(@Body() body: { email: string; password: string; displayName: string }) {
    return this.authService.register(body.email, body.password, body.displayName);
  }

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

  @Public()
  @Post('refresh')
  refresh(@Body() body: { refreshToken: string }) {
    return this.authService.refresh(body.refreshToken);
  }
}
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers['authorization'];
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException();
    }
    const token = authHeader.split(' ')[1];
    try {
      const payload = this.jwtService.verify(token, {
        secret: this.configService.get('JWT_ACCESS_SECRET'),
      });
      request.user = payload;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
import { Controller, Post, Get, Param, Body, Request } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';

@Controller('workspaces')
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  @Post()
  create(@Body() body: { name: string; slug: string }, @Request() req: any) {
    return this.workspacesService.create(body.name, body.slug, req.user.sub);
  }

  @Get()
  findAll(@Request() req: any) {
    return this.workspacesService.findByUser(req.user.sub);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }
}

Your API is now fully authenticated. Every request must carry a valid JWT, auth routes are properly exempted, and users can refresh their sessions without re-entering passwords. Next up, you will layer role-based access control on top of this foundation to restrict who can manage workspaces.

Add Role-Based Access Control

Your API can authenticate users, but every authenticated user currently has the same level of access. In a real multi-tenant system, workspace owners need different permissions than regular members.

In this step, you'll build a role-based access control (RBAC) system using a custom NestJS guard and decorator. The guard intercepts every request, checks the user's role within the target workspace, and blocks actions they don't have permission for.

In this step, get ready to:
  • Create a @Roles() decorator and RolesGuard that enforces workspace-level permissions.
  • Implement protected workspace management endpoints with role requirements.
  • Add a members endpoint using TypeORM query builder joins.
Create the @Roles() decorator and RolesGuard

NestJS guards implement the CanActivate interface and run before every route handler. You already have a global JwtAuthGuard that checks authentication. Now you'll add a second guard that checks authorization based on the user's role in a specific workspace.

The pattern works in two pieces: a decorator that attaches required roles as metadata to a route, and a guard that reads that metadata and compares it against the user's actual role.

  • Create a new file src/auth/roles.decorator.ts and add the following code:
import { SetMetadata } from '@nestjs/common';
import { MemberRole } from '../workspaces/workspace-member.entity';

// Metadata key used by the guard to read required roles
export const ROLES_KEY = 'roles';

// Decorator that attaches role requirements to a route handler
export const Roles = (...roles: MemberRole[]) => SetMetadata(ROLES_KEY, roles);

What does this code do?

SetMetadata is a NestJS utility that attaches key-value metadata to a route handler. When you write @Roles(MemberRole.ADMIN, MemberRole.OWNER) on a method, it stores those roles under the ROLES_KEY so the guard can retrieve them later.

The Reflector class (used in the guard next) reads this metadata at runtime to decide whether a user has sufficient permissions.

  • Save src/auth/roles.decorator.ts.
  • Create a new file src/auth/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 './roles.decorator';
import { MemberRole } from '../workspaces/workspace-member.entity';
import { WorkspacesService } from '../workspaces/workspaces.service';

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

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Read the roles metadata attached by @Roles() decorator
    const requiredRoles = this.reflector.getAllAndOverride<MemberRole[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    // If no roles are required, allow the request through
    if (!requiredRoles || requiredRoles.length === 0) return true;

    const request = context.switchToHttp().getRequest();
    const userId = request.user?.sub;
    const workspaceId = request.params?.id;
    if (!userId || !workspaceId) throw new ForbiddenException();

    // Look up the user's membership in this workspace
    const membership = await this.workspacesService.getMembership(workspaceId, userId);
    if (!membership) throw new ForbiddenException();

    // Compare user's role level against the minimum required level
    const roleHierarchy = [MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER];
    const userLevel = roleHierarchy.indexOf(membership.role);
    const requiredLevel = Math.min(...requiredRoles.map((r) => roleHierarchy.indexOf(r)));
    if (userLevel < requiredLevel) throw new ForbiddenException();
    return true;
  }
}

What does this code do?

The guard implements the full RBAC flow in a single canActivate method:

  • It reads which roles the current route requires using Reflector.getAllAndOverride.
  • It extracts the workspaceId from the route params and the userId from the JWT payload.
  • It queries the database for the user's membership in that workspace.
  • It compares roles using a hierarchy array where a higher index means more permissions.

If the route has no @Roles() decorator, the guard passes the request through. This means unannotated routes remain accessible to all authenticated users.

  • Save src/auth/roles.guard.ts.

The guard calls workspacesService.getMembership(), which doesn't exist yet. You also need to register the guard in the workspaces module so NestJS can inject WorkspacesService into it.

  • In src/workspaces/workspaces.service.ts, add this method at the bottom of the class (after the findById method):
  async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
    return this.membersRepository.findOne({ where: { workspaceId, userId } });
  }
  • In src/workspaces/workspaces.module.ts, add the RolesGuard import and register it as a provider. Replace the entire file with:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember } from './workspace-member.entity';
import { WorkspacesService } from './workspaces.service';
import { WorkspacesController } from './workspaces.controller';
import { RolesGuard } from '../auth/roles.guard';

@Module({
  imports: [TypeOrmModule.forFeature([Workspace, WorkspaceMember])],
  providers: [WorkspacesService, RolesGuard],
  controllers: [WorkspacesController],
  exports: [WorkspacesService],
})
export class WorkspacesModule {}

Why register RolesGuard in WorkspacesModule?

The guard depends on WorkspacesService to look up membership data. By registering RolesGuard in the same module that provides WorkspacesService, NestJS can inject the service into the guard automatically via dependency injection.

  • Save both workspaces.service.ts and workspaces.module.ts.
  • Confirm your app still compiles by checking the terminal running npm run start:dev. You should see no errors in the output.

Seeing a dependency injection error?

If NestJS throws an error about resolving dependencies of RolesGuard, make sure RolesGuard is listed in the providers array of workspaces.module.ts and that WorkspacesService is also in that same module's providers.

help me fix this NestJS dependency injection error with RolesGuard

Implement protected workspace management endpoints

With the guard and decorator ready, you can now protect specific routes by adding @Roles() annotations. You'll add four new endpoints: update a workspace name (admin and above), delete a workspace (owner only), add a member (admin and above), and remove a member (admin and above, but never the owner).

  • In src/workspaces/workspaces.service.ts, update the imports at the top of the file to include ForbiddenException and NotFoundException:
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
  • Add the following methods to the WorkspacesService class, below the getMembership method you just created:
  async update(id: string, name: string): Promise<Workspace> {
    const workspace = await this.workspacesRepository.findOne({ where: { id } });
    if (!workspace) throw new NotFoundException('Workspace not found');
    workspace.name = name;
    return this.workspacesRepository.save(workspace);
  }

  async delete(id: string): Promise<void> {
    await this.workspacesRepository.delete(id);
  }

  async addMember(workspaceId: string, userId: string, role: MemberRole): Promise<WorkspaceMember> {
    const member = this.membersRepository.create({ workspaceId, userId, role });
    return this.membersRepository.save(member);
  }

  async removeMember(workspaceId: string, userId: string): Promise<void> {
    const member = await this.membersRepository.findOne({ where: { workspaceId, userId } });
    if (!member) throw new NotFoundException('Member not found');
    if (member.role === MemberRole.OWNER) throw new ForbiddenException('Cannot remove workspace owner');
    await this.membersRepository.remove(member);
  }

What does this code do?

  • update finds a workspace by ID, changes its name, and saves it back. Throws NotFoundException if the workspace doesn't exist.
  • delete removes a workspace entirely from the database.
  • addMember creates a new WorkspaceMember record, linking a user to a workspace with a specified role.
  • removeMember deletes a membership record, but explicitly blocks removing the workspace owner as a safety check.
  • Save workspaces.service.ts.

Now wire these service methods to HTTP endpoints in the controller.

  • Replace the entire contents of src/workspaces/workspaces.controller.ts with the following:
import { Controller, Post, Get, Patch, Delete, Param, Body, Request, UseGuards } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { MemberRole } from './workspace-member.entity';

@Controller('workspaces')
@UseGuards(RolesGuard)
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  @Post()
  create(@Body() body: { name: string; slug: string }, @Request() req: any) {
    return this.workspacesService.create(body.name, body.slug, req.user.sub);
  }

  @Get()
  findAll(@Request() req: any) {
    return this.workspacesService.findByUser(req.user.sub);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }

  @Patch(':id')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  update(@Param('id') id: string, @Body() body: { name: string }) {
    return this.workspacesService.update(id, body.name);
  }

  @Delete(':id')
  @Roles(MemberRole.OWNER)
  remove(@Param('id') id: string) {
    return this.workspacesService.delete(id);
  }

  @Post(':id/members')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  addMember(@Param('id') workspaceId: string, @Body() body: { userId: string; role: MemberRole }) {
    return this.workspacesService.addMember(workspaceId, body.userId, body.role);
  }

  @Delete(':id/members/:userId')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  removeMember(@Param('id') workspaceId: string, @Param('userId') userId: string) {
    return this.workspacesService.removeMember(workspaceId, userId);
  }
}

What does this code do?

  • @UseGuards(RolesGuard) at the controller level means the guard runs for every route in this controller.
  • Routes without @Roles() (like create, findAll, findOne) pass through the guard because it returns true when no roles are specified.
  • @Roles(MemberRole.ADMIN, MemberRole.OWNER) means a user must be at least an admin to access that route.
  • @Roles(MemberRole.OWNER) restricts the route to workspace owners only.
  • Save workspaces.controller.ts.
  • Test the role-based protection by using curl or Postman. Register a user, create a workspace, then try to DELETE the workspace with a different user's token. You should receive a 403 Forbidden response.

Getting 403 on your own workspace?

Make sure the workspace ID in your request URL matches the id field from the workspace you created, not the slug. The guard extracts request.params.id and uses it to query the workspace_member table. If you pass a slug instead of a UUID, the membership lookup returns null and the guard denies access.

help me debug why I'm getting 403 Forbidden on my own workspace

Add a members list endpoint with query builder joins

The final endpoint returns all members of a workspace along with their user details (email, display name) and role. Instead of loading members and then making separate queries for each user, you'll use TypeORM's query builder to join the data in a single database query.

  • In src/workspaces/workspaces.service.ts, add this method to the WorkspacesService class (below removeMember):
  async getMembers(workspaceId: string): Promise<WorkspaceMember[]> {
    // Use query builder to join user data in a single query
    return this.membersRepository
      .createQueryBuilder('wm')
      .leftJoinAndSelect('wm.user', 'user')
      .where('wm.workspaceId = :workspaceId', { workspaceId })
      .getMany();
  }

What does this code do?

createQueryBuilder('wm') starts building a SQL query with wm as the alias for the workspace_member table.

leftJoinAndSelect('wm.user', 'user') performs a LEFT JOIN on the user relation defined in the WorkspaceMember entity, and selects all user columns. This gives you each member's full user profile alongside their role in a single round-trip to the database.

  • Save workspaces.service.ts.
  • In src/workspaces/workspaces.controller.ts, find the removeMember method. Add this new endpoint below it, inside the class:
  @Get(':id/members')
  getMembers(@Param('id') workspaceId: string) {
    return this.workspacesService.getMembers(workspaceId);
  }
  • Save workspaces.controller.ts.
  • Test the endpoint by sending a GET request to /workspaces/:id/members with a valid token. You should see an array of member objects, each containing a nested user object with email, displayName, and a role field.

Getting an empty user object in the response?

Make sure the @ManyToOne relation in workspace-member.entity.ts has @JoinColumn({ name: 'userId' }) matching the actual column name. If the join column doesn't match, the query builder can't resolve the relationship and returns null for the user.

help me fix empty user data in my TypeORM query builder join

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here are the complete files you created or modified in this step:

import { SetMetadata } from '@nestjs/common';
import { MemberRole } from '../workspaces/workspace-member.entity';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: MemberRole[]) => SetMetadata(ROLES_KEY, roles);
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
import { MemberRole } from '../workspaces/workspace-member.entity';
import { WorkspacesService } from '../workspaces/workspaces.service';

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

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredRoles = this.reflector.getAllAndOverride<MemberRole[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles || requiredRoles.length === 0) return true;

    const request = context.switchToHttp().getRequest();
    const userId = request.user?.sub;
    const workspaceId = request.params?.id;
    if (!userId || !workspaceId) throw new ForbiddenException();

    const membership = await this.workspacesService.getMembership(workspaceId, userId);
    if (!membership) throw new ForbiddenException();

    const roleHierarchy = [MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER];
    const userLevel = roleHierarchy.indexOf(membership.role);
    const requiredLevel = Math.min(...requiredRoles.map((r) => roleHierarchy.indexOf(r)));
    if (userLevel < requiredLevel) throw new ForbiddenException();
    return true;
  }
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember } from './workspace-member.entity';
import { WorkspacesService } from './workspaces.service';
import { WorkspacesController } from './workspaces.controller';
import { RolesGuard } from '../auth/roles.guard';

@Module({
  imports: [TypeOrmModule.forFeature([Workspace, WorkspaceMember])],
  providers: [WorkspacesService, RolesGuard],
  controllers: [WorkspacesController],
  exports: [WorkspacesService],
})
export class WorkspacesModule {}
import { Controller, Post, Get, Patch, Delete, Param, Body, Request, UseGuards } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { MemberRole } from './workspace-member.entity';

@Controller('workspaces')
@UseGuards(RolesGuard)
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

  @Post()
  create(@Body() body: { name: string; slug: string }, @Request() req: any) {
    return this.workspacesService.create(body.name, body.slug, req.user.sub);
  }

  @Get()
  findAll(@Request() req: any) {
    return this.workspacesService.findByUser(req.user.sub);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.workspacesService.findById(id);
  }

  @Patch(':id')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  update(@Param('id') id: string, @Body() body: { name: string }) {
    return this.workspacesService.update(id, body.name);
  }

  @Delete(':id')
  @Roles(MemberRole.OWNER)
  remove(@Param('id') id: string) {
    return this.workspacesService.delete(id);
  }

  @Post(':id/members')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  addMember(@Param('id') workspaceId: string, @Body() body: { userId: string; role: MemberRole }) {
    return this.workspacesService.addMember(workspaceId, body.userId, body.role);
  }

  @Delete(':id/members/:userId')
  @Roles(MemberRole.ADMIN, MemberRole.OWNER)
  removeMember(@Param('id') workspaceId: string, @Param('userId') userId: string) {
    return this.workspacesService.removeMember(workspaceId, userId);
  }

  @Get(':id/members')
  getMembers(@Param('id') workspaceId: string) {
    return this.workspacesService.getMembers(workspaceId);
  }
}
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Workspace } from './workspace.entity';
import { WorkspaceMember, MemberRole } from './workspace-member.entity';

@Injectable()
export class WorkspacesService {
  constructor(
    @InjectRepository(Workspace)
    private readonly workspacesRepository: Repository<Workspace>,
    @InjectRepository(WorkspaceMember)
    private readonly membersRepository: Repository<WorkspaceMember>,
  ) {}

  async create(name: string, slug: string, userId: string): Promise<Workspace> {
    const workspace = this.workspacesRepository.create({ name, slug });
    const saved = await this.workspacesRepository.save(workspace);
    const member = this.membersRepository.create({ userId, workspaceId: saved.id, role: MemberRole.OWNER });
    await this.membersRepository.save(member);
    return saved;
  }

  async findByUser(userId: string): Promise<Workspace[]> {
    const memberships = await this.membersRepository.find({ where: { userId }, relations: ['workspace'] });
    return memberships.map((m) => m.workspace);
  }

  async findById(id: string): Promise<Workspace | null> {
    return this.workspacesRepository.findOne({ where: { id } });
  }

  async update(id: string, name: string): Promise<Workspace> {
    const workspace = await this.workspacesRepository.findOne({ where: { id } });
    if (!workspace) throw new NotFoundException('Workspace not found');
    workspace.name = name;
    return this.workspacesRepository.save(workspace);
  }

  async delete(id: string): Promise<void> {
    await this.workspacesRepository.delete(id);
  }

  async addMember(workspaceId: string, userId: string, role: MemberRole): Promise<WorkspaceMember> {
    const member = this.membersRepository.create({ workspaceId, userId, role });
    return this.membersRepository.save(member);
  }

  async removeMember(workspaceId: string, userId: string): Promise<void> {
    const member = await this.membersRepository.findOne({ where: { workspaceId, userId } });
    if (!member) throw new NotFoundException('Member not found');
    if (member.role === MemberRole.OWNER) throw new ForbiddenException('Cannot remove workspace owner');
    await this.membersRepository.remove(member);
  }

  async getMembers(workspaceId: string): Promise<WorkspaceMember[]> {
    return this.membersRepository
      .createQueryBuilder('wm')
      .leftJoinAndSelect('wm.user', 'user')
      .where('wm.workspaceId = :workspaceId', { workspaceId })
      .getMany();
  }

  async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
    return this.membersRepository.findOne({ where: { workspaceId, userId } });
  }
}

Your API now enforces workspace-level permissions. Owners have full control, admins can manage members and settings, and regular members can only read. Next up, you'll extend this with a workspace invitation system in the Secret Mission.

Secret mission

Add a Workspace Invitation System

Your API supports adding members directly, but real SaaS products let admins send invite links. In this secret mission, you'll build a full invitation system with secure codes, 7-day expiration, and proper HTTP error handling for edge cases like expired or duplicate invites.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Docker container (postgres) running PostgreSQL.
  • Named Docker volume (postgres_data) storing your database files.
  • NestJS CLI installed globally via npm.
  • Local project folder (workspace-api) on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building on this project or plan to extend it further.

  • Your postgres container keeps running in the background. It uses minimal resources when idle.
  • The NestJS CLI stays available globally for future NestJS projects.
  • Your project folder remains on your Desktop, ready for you to pick up where you left off.

Pause - I'll come back to this later

Stop the running containers to free up system memory. Your data and project files stay intact.

  • Open a terminal and navigate to your workspace-api folder on your Desktop.
  • Stop the Docker containers by running:
docker compose stop
  • This stops the containers but preserves the postgres_data volume and all your database records.
  • When you're ready to resume, run docker compose up -d from the same folder to start everything back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your database data permanently.

  • Open a terminal and navigate to your workspace-api folder on your Desktop.
  • Remove the Docker containers and the database volume by running:
docker compose down -v
  • This removes the postgres container and deletes the postgres_data volume, erasing all database records.
  • Remove the NestJS CLI by running:
npm uninstall -g @nestjs/cli
  • Navigate up to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf workspace-api

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force workspace-api
  • All project files, containers, and database data are now removed from your machine.

Nice Work!

Nice Work!

Nice work! You just built a production-structured multi-tenant SaaS API from scratch using NestJS, PostgreSQL, and Docker. This is the kind of architecture that powers real enterprise backends.

You've learned how to:

  • Built a modular NestJS application with TypeORM entities, a relational PostgreSQL schema, and Docker Compose for local multi-service development.
  • Implemented JWT authentication with access/refresh token rotation, bcrypt password hashing, and token revocation support.
  • Built role-based access control using custom guards and decorators to protect workspace endpoints by membership level (owner, admin, member).
  • Secret Mission: Added a workspace invitation system with secure invite codes, 7-day expiration, and proper edge case handling (expired, already a member, invalid code).

Ready to quiz yourself?