My Journey Building Secure Auth in NestJS (and Everything I Learned)

Table of contents
When building secure and scalable web applications, one of the first challenges you’ll face is: how do you control who gets access to what? Whether you're managing a dashboard for admins or a public-facing portal for agents, the ability to define roles, enforce permissions, and guard sensitive areas is non-negotiable.
That’s where NestJS comes in—a powerful and modular framework built on top of Node.js. At first, it looked super complex—terms like JWT, Passport strategies, guards, decorators—ugh. But once I broke it down piece by piece, it all started to make sense. So in this blog, I’ll walk you through how I built my authentication system for a sample Project using NestJS, with code, explanations, and things I messed up (so you don’t have to).
What I Wanted to Build
A system where:
Admins and staff can log in to a dashboard
Agents can log in to a marketplace
Everyone's access is protected based on their roles
JWT tokens handle session verification
Passwords are hashed using Argon2 for security
Setting Up The Basics
I created a NestJS project and installed a few packages:
pnpm add @nestjs/passport passport passport-local passport-jwt
pnpm add @nestjs/jwt argon2
pnpm add drizzle-orm pg
pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken @types/passport-jwt @types/passport-local
I also set up PostgreSQL as my database using Drizzle ORM.
In my .env file, I made sure to include:
//depending on your port that is mentioned in your compose.yml file,change accordingly
DATABASE_URL=postgresql://user:password@localhost:yourport/yourdb
JWT_SECRET=your_super_secret_key
JWT_EXPIRES_IN=1h
JWT_ALGORITHM=HS256
Argon2 Password Hashing
Argon2 is used to compare hashed passwords—don’t ever store plain text!
import * as argon2 from "argon2";
export const hashPassword = async (plain: string): Promise<string> => {
return await argon2.hash(plain);
};
export const verifyPassword = async (
plain: string,
hashed: string
): Promise<boolean> => {
return await argon2.verify(hashed, plain);
};
Implementing Passport Strategies
NestJS uses Passport under the hood for strategy-based authentication.
1. User Registration Flow with Dual Table Insertion
For my project, i created two tables,user and account. User and account is linked with the userID column,due to which we need to create relations in the schema table.
users table mainly stores:
ID
Name
Email
Role (admin, staff, agent)
account table mainly stores:
UserId(which is linked to id in users table)
Hashed password (using Argon2)
Provider (
credentials
)
This dual insertion keeps identity and credentials separate—ideal for scaling purposes
2. Local Strategy — Email & Password Login
When someone logs in, I want to check their email and password. That’s handled by something called the LocalStrategy.
//local.strategy.ts
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: "email" }); // Instead of default 'username'
}
async validate(email: string, password: string): Promise<any> {
const account = await this.authService.validateCredentials(email, password);
if (!account) {
throw new UnauthorizedException("invalid email or password");
}
return account;
}
}
Here’s what’s happening:
We override Passport’s default field (which is
username
) and tell it to useemail
instead.When someone tries to log in, this
validate()
method runs.It calls our
AuthService
and asks, “Hey, does this email and password combo exist?”
Now you might wonder what is this ‘validateCredentials’ function is used for,for that let us check on the authService first
3. AuthService- Validate Credentials
Here’s the basic logic for validating the email and password:
@Injectable()
export class AuthService {
constructor(@Inject(DatabaseProvider) private db: Database) {}
async validateCredentials(email: string, password: string) {
const account = await this.db.query.accounts.findFirst({
where: (accounts: { accountId: any }, { eq }: any) =>
eq(accounts.accountId, email),
with: { user: true },
});
if (!account) return null;
const isValid = await verifyPassword(password, account.password);
if (!isValid) return null;
return account.user;
}
}
Here’s what this does:
It looks for a matching email in the
accounts
table.If found, it compares the stored hashed password with the one entered.
If valid, it returns user info (like
id
,role
, etc.).The ‘verifyPassword’ function is imported from the argon2 password hashing code that you can refer in the above password section
4.Jwt Token- Need To Be Created After User validation
Now inorder for the token to be issued whenever the user is validated,we need ‘login’ function in the authService:
async login(user: { id: string; email: string; role: string }) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.jwtService.sign(payload);
return {
message: "login succesful",
access_token: accessToken,
};
}
This particular code:”this.jwtService.sign(payload);” issues the token on that validated user whenever the user tries to login.
So the whole authService code can combine and form like this:
async validateCredentials(email: string, password: string) {
const account = await this.db.query.accounts.findFirst({
where: (accounts: { accountId: any }, { eq }: any) =>
eq(accounts.accountId, email),
with: { user: true },
});
if (!account) return null;
const isValid = await verifyPassword(password, account.password);
if (!isValid) return null;
return account.user;
}
async login(user: { id: string; email: string; role: string }) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.jwtService.sign(payload);
return {
message: "login succesful",
access_token: accessToken,
};
}
5. JWT Strategy — Verifying the Token For Every Protected Request
Once someone logs in successfully, we return a JWT token (like a digital key). Now, every time the user makes a request to a protected route, we need to verify that token.
That’s where JwtStrategy comes in
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
const jwtSecret = config.get<string>("JWT_SECRET");
if (!jwtSecret) {
throw new Error("jwt secret is not defined in env variables");
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
});
}
async validate(payload: any) {
// This is what ends up in req.user
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}
How this works:
The token is extracted from the
Authorization
header.It’s validated using the secret stored in your
.env
.If the token is valid, the
payload
(user’s ID,email and role) is attached to the request.ConfigService
is what you should use in TypeScript when accessing environment variables—instead of directly usingprocess.env
, which can often lead to undefined values or hard-to-trace bugs.Now, let’s protect the routes using a guard.
Guards — LocalAuthGuard and JwtAuthGuard
LocalAuthGuard
Used only on login routes — triggers LocalStrategy under the hood.
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}
JwtAuthGuard
Used for all protected routes that require a valid JWT token.
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
This is used instead of manually giving ‘local’ or ‘jwt’ in the argument section of Authguard whenever we apply it in authController.
This is because these magic strings are easier to:
Mistype the string (
'loacl'
instead of'local'
)Forget what the string refers to
Lose consistency if you later rename the strategy
In short: replacing magic strings with descriptive class names makes your authentication code more robust, readable, and maintainable.
Adding Role-Based Access with Custom Guard & Decorator
Okay, now we’ve handled login and JWT — but what if we want only Admins to access a specific route?Let’s create two things:
A
@Roles()
decorator to declare which roles can access somethingA
RolesGuard
to enforce that check
//roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
This is a custom decorator which Lets you write @Roles('admin')
above a controller method.
Behind the scenes, it tags that method with metadata: roles: ['admin']
Syntax of SetMetadata consist of key and value,therefore it is written in this manner.
//roles.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { ROLES_KEY } from "../decorators/roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {} //Reflector is a built-in NestJS tool to read metadata from decorators.
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredRoles) {
return true; // No @Roles() on this route, so let everyone in
}
const { user } = context.switchToHttp().getRequest();
if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException("Access denied for your role");
}
return true;
}
}
It looks at the route you're accessing.
If the route has
@Roles(...)
on it, it checks your role from the JWT payload.If the roles match → access granted.
If not → access denied.
Example Endpoints
// Admin & Staff register
POST /auth/register/admin
POST /auth/register/staff
// Agent register
POST /auth/register/agent
// Login
POST /auth/dashboard/login // For admin and staff
POST /auth/marketplace/login
AuthController
This is where we setup routes for the endpoints,let me show you the code snippet:
import {
Controller,
Post,
Body,
UseGuards,
Request,
ForbiddenException,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { RegisterDto } from "./dto/register.dto";
import { LocalAuthGuard } from "@/utils/guards/local-auth.guard";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("/register/admin")
async registerAdmin(@Body() data: RegisterDto) {
return await this.authService.register({
...data,
role: "admin",
});
}
@Post("/register/staff")
async registerStaff(@Body() data: RegisterDto) {
return await this.authService.register({
...data,
role: "staff",
});
}
@Post("register/agent")
async registerAgent(@Body() data: RegisterDto) {
return await this.authService.register({
...data,
role: "agent",
});
}
@UseGuards(LocalAuthGuard)
@Post("/marketplace/login")
async loginMarketplace(@Request() req) {
if (req.user.role !== "agent") {
throw new ForbiddenException("only agents can login");
}
return this.authService.login(req.user);
}
@UseGuards(LocalAuthGuard)
@Post("dashboard/login")
async loginDashboard(@Request() req) {
if (!["admin", "staff"].includes(req.user.role)) {
throw new ForbiddenException("only for admins and staff");
}
return this.authService.login(req.user);
}
}
//this is for showing the JwtAuthGuard and RolesGuard use:@Controller("products")
export class ProductsController {
constructor(private productsService: ProductsService) {}
@Get()
async all() {
return await this.productsService.findAll();
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("agent")
@Post("/create")
@UsePipes(ZodValidationPipe)
async create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
}
AuthModule
The AuthModule is basically where I connected all the pieces of my authentication system
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { PassportModule } from "@nestjs/passport";
import { LocalStrategy } from "@/utils/auth-utilities/local.strategy";
import { JwtStrategy } from "@/utils/auth-utilities/jwt.strategy";
import { JwtModule } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get("JWT_SECRET"),
signOptions: { expiresIn: config.get("JWT_EXPIRES_IN") },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}
Errors that i have crossed upon
Cannot read relations (Drizzle ORM)
Defined the relationship between users and accounts explicitly in the schema, especially when using
with: { users: true }
Unknown Strategy: local
Forgot to register
LocalStrategy
in AuthModule
Takeaways: What I Have Learned
This wasn’t just about wiring up a login and calling it a day—it was about really understanding how each piece of the system works and how they all talk to each other.
I ran into tons of issues—even for things that now feel obvious in hindsight. Errors that a seasoned dev might spot instantly took me hours.
But hey, no one’s born a professional. Sometimes, those frustrating bugs? They’re actually blessings in disguise—they push you to slow down, dig deeper, and level up.
Auth can definitely feel overwhelming at first. Strategies, guards, decorators—it’s a lot. But once I got the basics down and layered things step by step, it finally clicked.
My tip?
Start small. Understand the foundation first. Then revisit the docs and see how it all ties into your own code—it’ll start making real sense then.
Subscribe to my newsletter
Read articles from Adheeb Anvar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
