Skip to main content

NestJS — Authentication

This lecture adds JWT authentication to the task-api-nest project from lectures 70–71. The same dual-token pattern from lecture 67 — short-lived access token (JWT) + long-lived refresh token (database) — implemented with NestJS Passport and Guards. Auth concepts (bcrypt, JWT structure, token lifetimes) are covered in lecture 67 and not repeated here. This lecture focuses on the NestJS-specific implementation, protected routes, and connecting the frontend.

Lecture roadmap:

  1. Auth setup — Passport, JWT module, auth service
  2. JWT strategy & guards — token validation, route protection
  3. Account endpoints — register, login, refresh, logout
  4. Protected routes — global guard, user scoping
  5. Refresh token flow — rotation, cleanup
  6. Frontend integration — connecting Vue/React to NestJS
  7. Testing — unit and E2E tests
  8. Docker — containerized backend with PostgreSQL

Auth in NestJS vs Express

In lecture 67, you built auth manually: a verifyAccessToken function, an authenticate middleware, and direct JWT calls in route handlers. NestJS formalizes this with Passport strategies and guards:

AspectExpress (lecture 67)NestJS
Token validationjsonwebtoken.verify() in middlewarePassport JwtStrategy with automatic extraction
Route protectionrouter.use(authenticate)@UseGuards(JwtAuthGuard) decorator
User extractionreq.user set by middleware@CurrentUser() custom parameter decorator
Token generationManual jwt.sign() calls@nestjs/jwt JwtService.sign()
Password hashingDirect bcrypt callsSame — bcrypt is framework-agnostic

The underlying concepts are identical. NestJS adds structure and testability.


Passport & JWT Setup

Dependencies

npm install @nestjs/passport passport @nestjs/jwt passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt

AuthModule

// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./strategies/jwt.strategy";

@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET"),
signOptions: { expiresIn: "15m" }, // access token: 15 minutes
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

Add JWT_SECRET to your .env:

# .env — add to existing variables
JWT_SECRET=your-secret-key-change-this-in-production
JWT_REFRESH_EXPIRY_DAYS=7

Import AuthModule in AppModule:

// src/app.module.ts — add to imports array
import { AuthModule } from "./auth/auth.module";

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
TasksModule,
CategoriesModule,
],
})
export class AppModule {}

Auth service

The core auth logic — register, login, refresh, logout:

// src/auth/auth.service.ts
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import * as bcrypt from "bcrypt";
import { v4 as uuidv4 } from "uuid";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";

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

async register(dto: RegisterDto) {
// Check if email already exists
const existing = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException("Email already registered");
}

const passwordHash = await bcrypt.hash(dto.password, 12);

const user = await this.prisma.user.create({
data: {
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
},
});

return this.generateTokens(user.id, user.email);
}

async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});

if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
throw new UnauthorizedException("Invalid email or password");
}

return this.generateTokens(user.id, user.email);
}

async refresh(refreshToken: string) {
if (!refreshToken) {
throw new BadRequestException("Refresh token is required");
}

// Find the token in the database
const stored = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
});

if (!stored || stored.expiresAt < new Date()) {
// Delete expired token if it exists
if (stored) {
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
}
throw new UnauthorizedException("Invalid or expired refresh token");
}

// Token rotation: delete old token, issue new pair
await this.prisma.refreshToken.delete({ where: { id: stored.id } });

const user = await this.prisma.user.findUnique({
where: { id: stored.userId },
});
if (!user) {
throw new UnauthorizedException("User not found");
}

return this.generateTokens(user.id, user.email);
}

async logout(refreshToken: string) {
if (!refreshToken) return;

// Delete the refresh token — silently ignore if not found
await this.prisma.refreshToken.deleteMany({
where: { token: refreshToken },
});
}

private async generateTokens(userId: string, email: string) {
// Access token (JWT, 15 min)
const jwt = this.jwtService.sign({ sub: userId, email });

// Refresh token (opaque UUID, stored in DB)
const refreshToken = uuidv4();
// ConfigService returns strings from .env — parse to number explicitly
const expiryDays = parseInt(this.configService.get<string>("JWT_REFRESH_EXPIRY_DAYS") || "7", 10);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expiryDays);

await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId,
expiresAt,
},
});

return { jwt, refreshToken };
}
}

This mirrors the auth logic from lecture 67. Key differences:

  • JwtService.sign() instead of jsonwebtoken.sign() — the secret and expiry are configured in AuthModule
  • Prisma instead of raw SQL — typed queries, no SQL strings
  • NestJS exceptions (ConflictException, UnauthorizedException) instead of manual res.status() calls
Checkpoint 1 — auth service

The AuthService has all four methods (register, login, refresh, logout). No controller yet — we add that next.


JWT Strategy

JwtStrategy

Passport strategies define how to validate credentials. The JWT strategy extracts the token from the Authorization: Bearer header and verifies it:

// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../../prisma/prisma.service";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>("JWT_SECRET"),
});
}

// Called after JWT is verified — return value becomes req.user
async validate(payload: { sub: string; email: string }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});

if (!user) {
throw new UnauthorizedException();
}

return { userId: user.id, email: user.email };
}
}

The validate method runs after Passport verifies the JWT signature and expiry. It receives the decoded payload and returns the user object that becomes available in the request.

JwtAuthGuard

The guard applies the JWT strategy to routes:

// src/auth/guards/jwt-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

That's it — one line. AuthGuard("jwt") connects to the JwtStrategy (the name "jwt" comes from passport-jwt's default strategy name). When applied to a route, it:

  1. Extracts the Bearer token from the Authorization header
  2. Verifies the JWT signature and expiry
  3. Calls JwtStrategy.validate() to get the user
  4. Attaches the user to the request
  5. Returns 401 Unauthorized if any step fails

@CurrentUser decorator

A convenience decorator to extract the authenticated user from the request:

// src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";

export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user; // set by JwtStrategy.validate()
},
);

Usage in controllers:

@Get()
findAll(@CurrentUser() user: { userId: string; email: string }) {
return this.tasksService.findAll(user.userId);
}

This replaces the manual req.user access from Express (lecture 67). The decorator is typed and self-documenting.


Account Controller

Auth DTOs

// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

export class RegisterDto {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email: string;

@ApiProperty({ example: "password123", minLength: 8 })
@IsString()
@MinLength(8)
password: string;

@ApiProperty({ example: "John" })
@IsString()
@MinLength(1)
firstName: string;

@ApiProperty({ example: "Doe" })
@IsString()
@MinLength(1)
lastName: string;
}
// src/auth/dto/login.dto.ts
import { IsEmail, IsString } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

export class LoginDto {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email: string;

@ApiProperty({ example: "password123" })
@IsString()
password: string;
}
// src/auth/dto/refresh.dto.ts
import { IsString } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

export class RefreshDto {
@ApiProperty()
@IsString()
refreshToken: string;
}

Account controller

// src/auth/auth.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { AuthService } from "./auth.service";
import { RegisterDto } from "./dto/register.dto";
import { LoginDto } from "./dto/login.dto";
import { RefreshDto } from "./dto/refresh.dto";

@ApiTags("account")
@Controller("api/v1/account")
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post("register")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Register a new user" })
@ApiResponse({ status: 201, description: "User registered, tokens returned" })
@ApiResponse({ status: 409, description: "Email already registered" })
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}

@Post("login")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Login with email and password" })
@ApiResponse({ status: 200, description: "Tokens returned" })
@ApiResponse({ status: 401, description: "Invalid credentials" })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}

@Post("refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Refresh access token" })
@ApiResponse({ status: 200, description: "New token pair returned" })
@ApiResponse({ status: 401, description: "Invalid or expired refresh token" })
refresh(@Body() dto: RefreshDto) {
return this.authService.refresh(dto.refreshToken);
}

@Post("logout")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Logout — invalidate refresh token" })
logout(@Body() dto: RefreshDto) {
return this.authService.logout(dto.refreshToken);
}
}

Test with curl:

# Register
curl -X POST http://localhost:3000/api/v1/account/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"password123","firstName":"John","lastName":"Doe"}'

# Login
curl -X POST http://localhost:3000/api/v1/account/login \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"password123"}'

# Use the JWT from the response
curl http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer <paste-jwt-here>"
Checkpoint 2 — auth endpoints

Register, login, refresh, and logout endpoints work. Tokens are generated and stored. The task/category endpoints are not yet protected — that comes next.


Protecting Routes

Global guard with @Public

Instead of adding @UseGuards(JwtAuthGuard) to every controller, set it as the global default and mark public routes explicitly:

// src/auth/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";

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

Update the guard to check for the @Public decorator:

// src/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}

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

if (isPublic) return true;

return super.canActivate(context);
}
}

Register as global guard in AuthModule:

// src/auth/auth.module.ts — add to providers
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./guards/jwt-auth.guard";

@Module({
// ... imports, controllers
providers: [
AuthService,
JwtStrategy,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

Now every route requires a JWT by default. Mark public routes with @Public():

// src/auth/auth.controller.ts — mark all account routes as public
import { Public } from "./decorators/public.decorator";

@ApiTags("account")
@Controller("api/v1/account")
export class AuthController {
@Public()
@Post("register")
register(@Body() dto: RegisterDto) { /* ... */ }

@Public()
@Post("login")
login(@Body() dto: LoginDto) { /* ... */ }

@Public()
@Post("refresh")
refresh(@Body() dto: RefreshDto) { /* ... */ }

@Public()
@Post("logout")
logout(@Body() dto: RefreshDto) { /* ... */ }
}
Common mistake

Using per-controller @UseGuards instead of a global guard. With per-controller guards, every new controller starts unprotected — if you forget to add the guard, the endpoint is publicly accessible. With a global guard + @Public, every new endpoint is protected by default. You must explicitly opt out, which is safer.

User scoping

Update task and category controllers to use the authenticated user:

// src/tasks/tasks.controller.ts — updated with auth
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { ApiBearerAuth } from "@nestjs/swagger";
import { PatchTaskDto } from "./dto/patch-task.dto";

@ApiBearerAuth()
@ApiTags("tasks")
@Controller("api/v1/tasks")
export class TasksController {
constructor(private readonly tasksService: TasksService) {}

@Get()
findAll(
@CurrentUser() user: { userId: string },
@Query("status") status?: string,
@Query("priority") priority?: string,
) {
return this.tasksService.findAll(user.userId, status, priority);
}

@Get(":id")
findOne(@CurrentUser() user: { userId: string }, @Param("id") id: string) {
return this.tasksService.findOne(id, user.userId);
}

@Post()
@HttpCode(HttpStatus.CREATED)
create(@CurrentUser() user: { userId: string }, @Body() dto: CreateTaskDto) {
return this.tasksService.create(dto, user.userId);
}

@Put(":id")
update(
@CurrentUser() user: { userId: string },
@Param("id") id: string,
@Body() dto: UpdateTaskDto,
) {
return this.tasksService.update(id, dto, user.userId);
}

@Patch(":id")
partialUpdate(
@CurrentUser() user: { userId: string },
@Param("id") id: string,
@Body() dto: PatchTaskDto,
) {
return this.tasksService.partialUpdate(id, dto, user.userId);
}

@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
remove(@CurrentUser() user: { userId: string }, @Param("id") id: string) {
return this.tasksService.remove(id, user.userId);
}
}

The categories controller follows the same pattern — replace all "temp-user" with user.userId from @CurrentUser().


Refresh Token Flow

The flow is identical to lecture 67. Here is the same sequence diagram for reference:

Token rotation

The AuthService.refresh() method already implements single-use token rotation:

  1. Look up the refresh token in the database
  2. Verify it exists and is not expired
  3. Delete the old token
  4. Generate a new JWT + refresh token pair
  5. Store the new refresh token
  6. Return both tokens

If someone steals and uses a refresh token, the legitimate user's next refresh attempt fails (the token was deleted). This signals a potential compromise.

Cleanup strategy

Expired refresh tokens accumulate in the database. Clean them up periodically:

// src/auth/auth.service.ts — add cleanup method
async cleanupExpiredTokens(): Promise<number> {
const result = await this.prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}

Call it from a scheduled task or on each refresh call:

// Simple approach: clean up during refresh (before generating new tokens)
async refresh(refreshToken: string) {
// Clean up expired tokens for this user (lightweight)
await this.prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});

// ... rest of refresh logic
}
Checkpoint 3 — protected API

All task and category endpoints require a valid JWT. Users can only see and modify their own data. The refresh flow works end-to-end. Test with curl or Swagger UI (click "Authorize" and paste a JWT).


Frontend Integration

What changes when switching from Express to NestJS?

If the API surface is the same — same endpoint paths, same request/response shapes — nothing changes in the frontend. This is the value of REST API contracts. Your Vue or React app does not know or care whether the server is Express, NestJS, ASP.NET Core, or Django.

The task-api-nest uses the same endpoint paths as the Express task-api from lecture 67:

  • POST /api/v1/account/register — same request body, same response
  • POST /api/v1/account/login — same
  • POST /api/v1/account/refresh — same
  • GET/POST/PUT/PATCH/DELETE /api/v1/tasks — same
  • GET/POST/PUT/DELETE /api/v1/categories — same

API URL configuration

The only change: update the backend URL in your frontend .env if the port differs:

# Vue project (.env)
VITE_API_BASE_URL=http://localhost:3000/api/v1

# React/Next.js project (.env.local)
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/v1

Axios interceptor

The Axios interceptor from lecture 53 (Vue) handles the JWT refresh flow automatically — it catches 401 responses, calls /account/refresh, and retries the original request. This works unchanged with the NestJS backend because the API contract is identical.

If you have not implemented the interceptor yet, here is the pattern:

// src/services/api.ts — Vue (Vite)
import axios from "axios";

const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});
// For Next.js, use: baseURL: process.env.NEXT_PUBLIC_API_BASE_URL

// Request interceptor — attach JWT to every request
api.interceptors.request.use((config) => {
const jwt = localStorage.getItem("jwt");
if (jwt) {
config.headers.Authorization = `Bearer ${jwt}`;
}
return config;
});

// Response interceptor — auto-refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
// No refresh token — redirect to login
window.location.href = "/login";
return Promise.reject(error);
}

try {
const { data } = await axios.post(
`${api.defaults.baseURL}/account/refresh`,
{ refreshToken },
);
localStorage.setItem("jwt", data.jwt);
localStorage.setItem("refreshToken", data.refreshToken);

originalRequest.headers.Authorization = `Bearer ${data.jwt}`;
return api(originalRequest); // retry with new token
} catch {
localStorage.removeItem("jwt");
localStorage.removeItem("refreshToken");
window.location.href = "/login";
return Promise.reject(error);
}
}

return Promise.reject(error);
},
);

export default api;
warning

Storing JWTs in localStorage is the simplest approach and sufficient for this course. Production apps should consider httpOnly cookies (prevents XSS from reading the token) or a combination of both. See lecture 7 for the security tradeoffs.


Testing

info

NestJS scaffolds Jest as the default test runner, while the rest of this course uses Vitest (lecture 55). The two are API-compatible — describe, it, expect, beforeEach work the same way. The key difference: Jest uses jest.fn() for mocks, Vitest uses vi.fn(). NestJS's TestingModule integration is built around Jest, so we use Jest here. If you prefer Vitest, install @nestjs/testing and configure it — the test code is nearly identical.

Unit testing a service

NestJS's TestingModule creates an isolated module with mocked dependencies:

// src/auth/auth.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { ConflictException, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { PrismaService } from "../prisma/prisma.service";
import * as bcrypt from "bcrypt";

describe("AuthService", () => {
let service: AuthService;
let prisma: PrismaService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: PrismaService,
useValue: {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
refreshToken: {
create: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
},
},
},
{
provide: JwtService,
useValue: {
sign: jest.fn().mockReturnValue("mock-jwt-token"),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue(7),
},
},
],
}).compile();

service = module.get<AuthService>(AuthService);
prisma = module.get<PrismaService>(PrismaService);
});

describe("register", () => {
it("should create a new user and return tokens", async () => {
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
(prisma.user.create as jest.Mock).mockResolvedValue({
id: "user-1",
email: "test@test.com",
});
(prisma.refreshToken.create as jest.Mock).mockResolvedValue({});

const result = await service.register({
email: "test@test.com",
password: "password123",
firstName: "John",
lastName: "Doe",
});

expect(result).toHaveProperty("jwt");
expect(result).toHaveProperty("refreshToken");
expect(prisma.user.create).toHaveBeenCalled();
});

it("should throw ConflictException if email exists", async () => {
(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: "existing" });

await expect(
service.register({
email: "test@test.com",
password: "password123",
firstName: "John",
lastName: "Doe",
}),
).rejects.toThrow(ConflictException);
});
});

describe("login", () => {
it("should throw UnauthorizedException for wrong password", async () => {
(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: "user-1",
email: "test@test.com",
passwordHash: await bcrypt.hash("correct-password", 12),
});

await expect(
service.login({ email: "test@test.com", password: "wrong-password" }),
).rejects.toThrow(UnauthorizedException);
});
});
});

Key testing patterns:

  • Mock PrismaService with jest.fn() — no real database needed
  • Mock JwtService — return predictable tokens
  • Test behaviour, not implementation — assert on return values and thrown exceptions

E2E testing

E2E tests use supertest with the full NestJS app — same concept as lecture 67, but with NestJS's TestingModule:

// test/app.e2e-spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "../src/app.module";
import { PrismaService } from "../src/prisma/prisma.service";

describe("App (e2e)", () => {
let app: INestApplication;
let jwt: string;
let refreshToken: string;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();

// Clean up test data from previous runs
const prisma = app.get(PrismaService);
await prisma.refreshToken.deleteMany();
await prisma.task.deleteMany();
await prisma.category.deleteMany();
await prisma.user.deleteMany();
});

afterAll(async () => {
await app.close();
});

it("POST /api/v1/account/register — creates user", async () => {
const response = await request(app.getHttpServer())
.post("/api/v1/account/register")
.send({
email: "e2e@test.com",
password: "password123",
firstName: "E2E",
lastName: "Test",
})
.expect(201);

expect(response.body).toHaveProperty("jwt");
expect(response.body).toHaveProperty("refreshToken");
jwt = response.body.jwt;
refreshToken = response.body.refreshToken;
});

it("GET /api/v1/tasks — requires auth", async () => {
await request(app.getHttpServer())
.get("/api/v1/tasks")
.expect(401);
});

it("GET /api/v1/tasks — returns tasks with valid JWT", async () => {
await request(app.getHttpServer())
.get("/api/v1/tasks")
.set("Authorization", `Bearer ${jwt}`)
.expect(200);
});

it("POST /api/v1/tasks — creates a task", async () => {
const response = await request(app.getHttpServer())
.post("/api/v1/tasks")
.set("Authorization", `Bearer ${jwt}`)
.send({ title: "E2E Test Task" })
.expect(201);

expect(response.body.title).toBe("E2E Test Task");
});
});
info

E2E tests need a running PostgreSQL instance. Use a separate test database (set DATABASE_URL in your test environment) to avoid corrupting development data. The beforeAll cleanup above deletes all rows before each test run — this makes tests repeatable. Run migrations before tests: npx prisma migrate deploy.

Checkpoint 4 — tested and complete

The task-api-nest project has JWT authentication, database persistence, Swagger documentation, unit tests, and E2E tests. The next section packages it for deployment.


Docker Setup

This section covers containerizing the NestJS backend with PostgreSQL. For frontend containerization, reverse proxying, and full deployment pipelines, see lecture 80.

Updated docker-compose.yml

NestJS app + PostgreSQL:

# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://taskuser:taskpass@db:5432/taskdb
- JWT_SECRET=${JWT_SECRET}
- JWT_REFRESH_EXPIRY_DAYS=7
- PORT=3000
- CORS_ORIGIN=http://localhost:5173
depends_on:
db:
condition: service_healthy

db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=taskuser
- POSTGRES_PASSWORD=taskpass
- POSTGRES_DB=taskdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
interval: 5s
timeout: 5s
retries: 5

volumes:
pgdata:

Dockerfile

# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma

EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
# .dockerignore
node_modules
dist
.env
*.md
.git
# Build and run
docker compose up --build

Express vs NestJS Summary

Now that both versions of the task API are complete, here is how they compare:

AspectExpress task-api (lectures 66-67)NestJS task-api-nest (lectures 70-72)
Files~10 files~30 files
StructureFlat — routes, middleware, dbModular — feature modules with controllers, services, DTOs
Validation40+ lines of manual checksDecorators on DTO classes
DatabaseRaw SQL with better-sqlite3Prisma ORM with PostgreSQL
AuthManual JWT middlewarePassport strategy + Guard
Error handlingCustom middlewareBuilt-in exception classes + filter
API docsNone (or manual)Swagger auto-generated
Testingsupertest onlyUnit tests (mocked DI) + E2E (supertest)
Type safetyPartial — manual type assertionsFull — Prisma generates types from schema
Learning curveLowerHigher
When to useSmall APIs, learning, prototypesTeam projects, enterprise, A6 assignment

Both produce the same API — same endpoints, same request/response shapes. The frontend does not care which one you use.


Defence Preparation

Be prepared to explain topics like these:

  1. What is Passport in NestJS and how does a JWT strategy work? — Passport is an authentication middleware library. NestJS wraps it with @nestjs/passport, which integrates strategies into the NestJS guard system. A JWT strategy (passport-jwt) extracts the Bearer token from the Authorization header, verifies the signature using the secret key, checks the expiry, and calls a validate() method where you look up the user. If validation succeeds, the user object is attached to the request. If any step fails, the guard returns 401 Unauthorized.

  2. How does @UseGuards(JwtAuthGuard) differ from Express middleware for auth? — In Express, router.use(authenticate) applies middleware to all routes below it — it is order-dependent and positional. In NestJS, @UseGuards is declarative — you apply it to specific methods, entire controllers, or globally. It integrates with NestJS's dependency injection (the guard can inject services) and metadata reflection (the @Public decorator can override it). NestJS guards run after middleware but before pipes and handlers, giving them a well-defined position in the request lifecycle.

  3. Why use a global guard with @Public instead of per-controller guards? — With per-controller guards, every new controller or endpoint starts unprotected. If a developer forgets to add the guard, the endpoint is publicly accessible — a security vulnerability that passes code review because nothing looks wrong. With a global guard, everything is protected by default. You must explicitly add @Public() to opt out, which is visible in the code and intentional. This is a "secure by default" pattern.

  4. Why does the refresh endpoint not use JwtAuthGuard? — The refresh endpoint is called when the access token has already expired — the client cannot send a valid JWT. If the endpoint required a valid JWT, the client could never refresh. The refresh endpoint is marked @Public() and validates the refresh token (an opaque UUID looked up in the database) instead of the JWT. The refresh token provides its own authentication mechanism — it proves the client was previously authenticated.

  5. How do you unit test a NestJS service that depends on PrismaService? — Use NestJS's TestingModule to create an isolated module where PrismaService is replaced with a mock object. The mock has the same method signatures but returns fake data (jest.fn().mockResolvedValue(...)). The service under test receives the mock through dependency injection and does not know it is not the real database. This lets you test business logic (validation, error handling, token generation) without a running database, making tests fast and deterministic.

  6. What changes in the Vue/React frontend when switching from Express to NestJS? — Nothing, if the API contract is identical. Both backends expose the same endpoints (/api/v1/account/login, /api/v1/tasks, etc.) with the same request bodies and response shapes. The Axios interceptor, auth store, and API service functions work unchanged. This is the purpose of a REST API contract — the implementation behind the endpoints can be Express, NestJS, ASP.NET Core, or any other framework. The only change is the backend URL in the .env file if the port differs.

  7. How does NestJS TestingModule differ from direct supertest usage in Express? — In Express (lecture 67), you import app and pass it to supertest — this runs the full app with real middleware, real database, and real auth. In NestJS, TestingModule can do both: (1) create the full app for E2E tests (same as Express supertest), or (2) create an isolated module with mocked providers for unit tests. The DI system makes this possible — you override specific providers while keeping everything else real. This gives you fast unit tests for individual services and comprehensive E2E tests for the full stack.