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:
- Auth setup — Passport, JWT module, auth service
- JWT strategy & guards — token validation, route protection
- Account endpoints — register, login, refresh, logout
- Protected routes — global guard, user scoping
- Refresh token flow — rotation, cleanup
- Frontend integration — connecting Vue/React to NestJS
- Testing — unit and E2E tests
- 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:
| Aspect | Express (lecture 67) | NestJS |
|---|---|---|
| Token validation | jsonwebtoken.verify() in middleware | Passport JwtStrategy with automatic extraction |
| Route protection | router.use(authenticate) | @UseGuards(JwtAuthGuard) decorator |
| User extraction | req.user set by middleware | @CurrentUser() custom parameter decorator |
| Token generation | Manual jwt.sign() calls | @nestjs/jwt JwtService.sign() |
| Password hashing | Direct bcrypt calls | Same — 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 ofjsonwebtoken.sign()— the secret and expiry are configured inAuthModule- Prisma instead of raw SQL — typed queries, no SQL strings
- NestJS exceptions (
ConflictException,UnauthorizedException) instead of manualres.status()calls
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:
- Extracts the Bearer token from the
Authorizationheader - Verifies the JWT signature and expiry
- Calls
JwtStrategy.validate()to get the user - Attaches the user to the request
- Returns
401 Unauthorizedif 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>"
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) { /* ... */ }
}
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:
- Look up the refresh token in the database
- Verify it exists and is not expired
- Delete the old token
- Generate a new JWT + refresh token pair
- Store the new refresh token
- 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
}
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 responsePOST /api/v1/account/login— samePOST /api/v1/account/refresh— sameGET/POST/PUT/PATCH/DELETE /api/v1/tasks— sameGET/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;
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
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");
});
});
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.
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 /app/dist ./dist
COPY /app/node_modules/.prisma ./node_modules/.prisma
COPY /app/node_modules/@prisma ./node_modules/@prisma
COPY /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:
| Aspect | Express task-api (lectures 66-67) | NestJS task-api-nest (lectures 70-72) |
|---|---|---|
| Files | ~10 files | ~30 files |
| Structure | Flat — routes, middleware, db | Modular — feature modules with controllers, services, DTOs |
| Validation | 40+ lines of manual checks | Decorators on DTO classes |
| Database | Raw SQL with better-sqlite3 | Prisma ORM with PostgreSQL |
| Auth | Manual JWT middleware | Passport strategy + Guard |
| Error handling | Custom middleware | Built-in exception classes + filter |
| API docs | None (or manual) | Swagger auto-generated |
| Testing | supertest only | Unit tests (mocked DI) + E2E (supertest) |
| Type safety | Partial — manual type assertions | Full — Prisma generates types from schema |
| Learning curve | Lower | Higher |
| When to use | Small APIs, learning, prototypes | Team 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:
-
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 theAuthorizationheader, verifies the signature using the secret key, checks the expiry, and calls avalidate()method where you look up the user. If validation succeeds, the user object is attached to the request. If any step fails, the guard returns401 Unauthorized. -
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,@UseGuardsis 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@Publicdecorator can override it). NestJS guards run after middleware but before pipes and handlers, giving them a well-defined position in the request lifecycle. -
Why use a global guard with
@Publicinstead 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. -
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. -
How do you unit test a NestJS service that depends on PrismaService? — Use NestJS's
TestingModuleto 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. -
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.envfile if the port differs. -
How does NestJS
TestingModulediffer from directsupertestusage in Express? — In Express (lecture 67), you importappand pass it tosupertest— this runs the full app with real middleware, real database, and real auth. In NestJS,TestingModulecan do both: (1) create the full app for E2E tests (same as Expresssupertest), 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.