This is part 2 of a 2-part series on building production-ready authentication in NestJS. If you haven't read Part 1: Foundation & Setup, start there first - we'll be building on the foundation established in that part.
Okay, so you've got the foundation from Part 1. Now comes the fun part – actually making people log in and not letting random strangers mess with your app.
The Authentication Flow I Actually Built
After fighting with authentication for way too long, here's what I finally got working:
- Sign up (but can't do anything until they verify email)
- Get an email verification link (printed to console for now, because I'm not setting up SMTP in this tutorial)
- Verify their email and automatically log in
- Login normally after that
- Update their password without breaking everything
- Log out properly
The system uses HTTP-only cookies for the JWT tokens.
Setting Up the Data Structures
First, I need to define what the authentication inputs and outputs look like:
// src/auth/dto/signup.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, MinLength, IsString } from 'class-validator';
@InputType()
export class SignupInput {
@Field()
@IsString()
@IsNotEmpty()
name: string;
@Field()
@IsEmail()
@IsNotEmpty()
email: string;
@Field()
@IsNotEmpty()
@MinLength(8)
password: string;
@Field()
@IsNotEmpty()
@MinLength(8)
passwordConfirm: string;
}
// src/auth/dto/login.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';
@InputType()
export class LoginInput {
@Field()
@IsEmail()
@IsNotEmpty()
email: string;
@Field()
@IsNotEmpty()
password: string;
}
// src/auth/dto/update-password.input.ts
import { IsString, MinLength, IsNotEmpty } from 'class-validator';
import { InputType, Field } from '@nestjs/graphql';
@InputType()
export class UpdatePasswordInput {
@Field()
@IsString()
@IsNotEmpty({ message: 'Current password is required' })
currentPassword: string;
@Field()
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
password: string;
@Field()
@IsString()
@IsNotEmpty({ message: 'Password confirmation is required' })
passwordConfirm: string;
}
For the responses, I keep it simple:
// src/auth/dto/auth-response.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';
import { User } from 'src/user/user.entity';
@ObjectType()
export class AuthResponse {
@Field(() => User)
user: User;
}
// src/auth/dto/signup-response.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class SignupResponse {
@Field()
message: string;
}
Notice I'm not returning the JWT token in the GraphQL response? That's because it's going straight into an HTTP-only cookie where JavaScript can't touch it.
The Authentication Service
This is where I spent most of my time debugging weird edge cases. The AuthService handles the entire authentication flow:
// src/auth/auth.service.ts
import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SignupInput } from './dto/signup.input';
import { LoginInput } from './dto/login.input';
import { PasswordService } from './password.service';
import { UserService } from 'src/user/user.service';
import { User } from 'src/user/user.entity';
import { UpdatePasswordInput } from './dto/update-password.input';
interface VerificationTokenPayload {
email: string;
sub: string;
iat?: number;
exp?: number;
}
@Injectable()
export class AuthService {
constructor(
private usersService: UserService,
private jwtService: JwtService,
private passwordService: PasswordService,
) {}
async signup(
signupInput: SignupInput,
): Promise<{ message: string; verificationToken?: string }> {
// Check passwords match - doing this first saves a database call
if (signupInput.password !== signupInput.passwordConfirm) {
throw new BadRequestException('Passwords do not match');
}
const existingUser = await this.usersService.findByEmail(signupInput.email);
if (existingUser) {
throw new BadRequestException('Email already in use');
}
// Give them 15 minutes to verify their email
const expiresDate = new Date();
expiresDate.setMinutes(expiresDate.getMinutes() + 15);
const user = await this.usersService.create({
name: signupInput.name,
email: signupInput.email,
password: signupInput.password,
isEmailVerified: false,
emailVerificationExpires: expiresDate,
});
// Generate verification token (short-lived)
const verificationToken = this.generateVerificationToken(user);
// In real life, you'd send this via email service
// For now, just log it so you can test
console.log(`Verification token for ${user.email}: ${verificationToken}`);
return { message: 'Please check your email for verification link' };
}
async verifyEmail(token: string): Promise<{ token: string; user: User }> {
try {
const decoded = this.jwtService.verify<VerificationTokenPayload>(token);
const user = await this.usersService.findById(decoded.sub);
if (!user) {
throw new UnauthorizedException('Invalid token');
}
// Check if already verified
if (user.isEmailVerified) {
throw new BadRequestException('Email already verified');
}
// Check if verification expired
if (
user.emailVerificationExpires &&
user.emailVerificationExpires < new Date()
) {
// Clean up the unverified user account
await this.usersService.remove(user.id);
throw new UnauthorizedException(
'Verification link has expired. Please sign up again.',
);
}
// Mark email as verified
await this.usersService.updateEmailVerification({
userId: user.id,
isEmailVerified: true,
emailVerificationExpires: null,
});
// Get updated user and generate access token
const updatedUser = await this.usersService.findById(user.id);
const accessToken = this.generateToken(updatedUser);
return { token: accessToken, user: updatedUser };
} catch {
throw new UnauthorizedException('Invalid or expired verification token');
}
}
async login(loginInput: LoginInput): Promise<{ token: string; user: User }> {
const user = await this.usersService.findByEmail(loginInput.email);
if (!user) {
// Same error message whether email exists or not (prevents email enumeration)
throw new UnauthorizedException('Invalid email or password');
}
// Verify password using PasswordService
const isPasswordValid = await this.passwordService.verify(
loginInput.password,
user.password,
);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid email or password');
}
// Don't let unverified users log in
if (!user.isEmailVerified) {
throw new UnauthorizedException(
'Email not verified. Please verify your email before logging in.',
);
}
// All good - generate token
const token = this.generateToken(user);
return { token, user };
}
async updatePassword(
userId: string,
updatePasswordInput: UpdatePasswordInput,
): Promise<{ token: string; user: User }> {
const user = await this.usersService.findById(userId);
// Check if current password is correct using PasswordService
const isCurrentPasswordValid = await this.passwordService.verify(
updatePasswordInput.currentPassword,
user.password,
);
if (!isCurrentPasswordValid) {
throw new BadRequestException('Current password is incorrect');
}
// Check if new passwords match
if (updatePasswordInput.password !== updatePasswordInput.passwordConfirm) {
throw new BadRequestException('Passwords do not match');
}
// Update password - hashing handled by UsersService
await this.usersService.update(userId, {
password: updatePasswordInput.password,
});
const updatedUser = await this.usersService.findById(userId);
const token = this.generateToken(updatedUser);
return { token, user: updatedUser };
}
private generateToken(user: User): string {
const payload = { email: user.email, sub: user.id, roles: user.roles };
return this.jwtService.sign(payload);
}
private generateVerificationToken(user: User): string {
const payload = { email: user.email, sub: user.id };
// Verification tokens are short-lived
return this.jwtService.sign(payload, {
expiresIn: '15m',
});
}
}
The key insight here is that I use two different token types: short-lived verification tokens and longer-lived access tokens.
Also notice how I clean up expired user registrations automatically. If someone signs up but doesn't verify their email in time, their account gets deleted when they try to use the expired token. This prevents the database from filling up with junk accounts.
Decorators
These decorators make the code way cleaner and easier to read:
// 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);
// src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from 'src/user/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Request } from 'express';
import { User } from 'src/user/user.entity';
interface GqlContext {
req: Request;
}
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
const gqlContext = ctx.getContext<GqlContext>();
return gqlContext.req.user as User;
},
);
Now I can use @CurrentUser()
to get the authenticated user in any resolver. Much cleaner than manually extracting it from the context every time.
Guards and Strategies
Here's where things get interesting. Guards are like bouncers for your app - they decide who gets in and who doesn't. But understanding how they work took me way too long, so let me break it down properly.
JWT Strategy (Getting Tokens from Cookies)
// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { TypedConfigService } from 'src/config/typed-config.service';
import { AuthConfig } from 'src/config/auth.config';
import { RequestWithCookies } from 'src/types/express';
import { UserService } from 'src/user/user.service';
import { Role } from 'src/user/role.enum';
interface JwtPayload {
sub: string;
email: string;
roles?: Role[];
iat?: number;
exp?: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: TypedConfigService,
private readonly usersService: UserService,
) {
const auth = config.get<AuthConfig>('auth');
if (!auth) {
throw new Error('Auth config not found');
}
const jwtFromRequest = (req: RequestWithCookies) => {
if (req.cookies?.airbnbCloneJWT) {
return req.cookies.airbnbCloneJWT;
}
};
super({
jwtFromRequest,
ignoreExpiration: false,
secretOrKey: auth.jwt.secret,
});
}
async validate(payload: JwtPayload) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User no longer exists');
}
return user;
}
}
The Main Auth Guard (Your App's Default Security)
// src/auth/guards/auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
interface GqlContext {
req: Request;
}
@Injectable()
export class GqlAuthGuard 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);
}
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
const gqlContext = ctx.getContext<GqlContext>();
return gqlContext.req;
}
}
Here's the important part that confused me for hours: This guard is applied globally (you'll see how in the module setup). That means every single GraphQL operation requires authentication by default.
Want a route to be public? You have to explicitly mark it with @Public()
. It's way more secure - you can't accidentally forget to protect a route.
Role-Based Access Guard (For Admin Stuff)
// src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { Role } from 'src/user/role.enum';
import { GqlContext } from 'src/types/express';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const ctx = GqlExecutionContext.create(context);
const gqlContext = ctx.getContext<GqlContext>();
const req = gqlContext.req;
const user = req.user;
if (!user) {
return false;
}
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
This guard is also applied globally, but it only kicks in when you use the @Roles()
decorator. Here's how you'd use it:
@Roles(Role.ADMIN)
@Query(() => [User])
async getAllUsers(): Promise<User[]> {
// Only admins can access this
return this.usersService.findAll();
}
The guard checks if the user has at least one of the required roles. If they don't, they get rejected.
Email Verification Guard (For Verified Users Only)
// src/auth/guards/verified-email.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { GqlContext } from 'src/types/express';
@Injectable()
export class VerifiedEmailGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const gqlContext = ctx.getContext<GqlContext>();
const req = gqlContext.req;
const user = req.user;
if (!user) {
return false;
}
if (!user.isEmailVerified) {
throw new UnauthorizedException('Email not verified');
}
return true;
}
}
This one is not applied globally. You use it on specific routes where you want to make sure the user has verified their email:
@UseGuards(VerifiedEmailGuard)
@Mutation(() => AuthResponse)
async updatePassword(
@CurrentUser() user: User,
@Args('updatePasswordInput') updatePasswordInput: UpdatePasswordInput,
): Promise<AuthResponse> {
// Only verified users can change their password
}
Cookie Management
Managing cookies securely is more complex than it should be:
// src/auth/utils/token.utils.ts
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { User } from 'src/user/user.entity';
@Injectable()
export class TokenUtils {
constructor(private configService: ConfigService) {}
setCookieToken(res: Response, token: string): void {
const cookieExpiresInDays = parseInt(
this.configService.get('JWT_COOKIE_EXPIRES_IN') || '7',
10,
);
const cookieOptions = {
expires: new Date(Date.now() + cookieExpiresInDays * 24 * 60 * 60 * 1000),
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
path: '/',
};
res.cookie('airbnbCloneJWT', token, cookieOptions);
}
createSendToken(
user: User,
statusCode: number,
res: Response,
token: string,
): any {
this.setCookieToken(res, token);
const userResponse = { ...user } as Partial<User>;
delete userResponse.password;
return {
user: userResponse,
};
}
}
The security options here are crucial:
-
httpOnly
: Prevents XSS attacks by making the cookie inaccessible to JavaScript -
secure
: Only sends cookie over HTTPS in production -
path: '/'
: Makes cookie available to all routes
The GraphQL Resolver (Where It All Comes Together)
Finally, the resolver that ties everything together:
// src/auth/auth.resolver.ts
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { AuthResponse } from './dto/auth-response.dto';
import { SignupInput } from './dto/signup.input';
import { LoginInput } from './dto/login.input';
import { SignupResponse } from './dto/signup-response.dto';
import { TokenUtils } from './utils/token.utils';
import { Public } from './decorators/public.decorator';
import { GqlContext } from 'src/types/express';
import { CurrentUser } from './decorators/current-user.decorator';
import { UseGuards } from '@nestjs/common';
import { User } from 'src/user/user.entity';
import { VerifiedEmailGuard } from './guards/verified-email.guard';
import { UpdatePasswordInput } from './dto/update-password.input';
@Resolver()
export class AuthResolver {
constructor(
private readonly authService: AuthService,
private readonly tokenUtils: TokenUtils,
) {}
@Public()
@Mutation(() => SignupResponse)
async signup(
@Args('signupInput') signupInput: SignupInput,
): Promise<SignupResponse> {
return this.authService.signup(signupInput);
}
@Public()
@Mutation(() => AuthResponse)
async verifyEmail(
@Args('token') token: string,
@Context() context: GqlContext,
): Promise<AuthResponse> {
const { token: authToken, user } =
await this.authService.verifyEmail(token);
// Set the token in an HTTP-only cookie only
this.tokenUtils.setCookieToken(context.res, authToken);
return { user };
}
@Public()
@Mutation(() => AuthResponse)
async login(
@Args('loginInput') loginInput: LoginInput,
@Context() { res }: GqlContext,
): Promise<AuthResponse> {
const { token, user } = await this.authService.login(loginInput);
// Set the token in an HTTP-only cookie only
this.tokenUtils.setCookieToken(res, token);
return { user };
}
@Mutation(() => AuthResponse)
@UseGuards(VerifiedEmailGuard)
async updatePassword(
@CurrentUser() user: User,
@Args('updatePasswordInput') updatePasswordInput: UpdatePasswordInput,
@Context() context: GqlContext,
): Promise<AuthResponse> {
const result = await this.authService.updatePassword(
user.id,
updatePasswordInput,
);
// Set the new token in an HTTP-only cookie only
this.tokenUtils.setCookieToken(context.res, result.token);
return { user: result.user };
}
@Mutation(() => Boolean)
// eslint-disable-next-line @typescript-eslint/require-await
async logout(@Context() context: GqlContext): Promise<boolean> {
// Clear the cookie with secure options
context.res.clearCookie('airbnbCloneJWT', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return true;
}
}
Notice how the public mutations use @Public()
decorator, while updatePassword
requires a verified email with @UseGuards(VerifiedEmailGuard)
.
Also, I generate a new JWT after password updates since the user's credentials changed. This invalidates any other active sessions.
Wiring It All Together
Finally, the AuthModule that makes everything work:
// src/auth/auth.module.ts
import { Module, forwardRef } 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 { AuthResolver } from './auth.resolver';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PasswordService } from './password.service';
import { TypedConfigService } from 'src/config/typed-config.service';
import { AuthConfig } from 'src/config/auth.config';
import { RolesGuard } from './guards/roles.guard';
import { APP_GUARD } from '@nestjs/core';
import { GqlAuthGuard } from './guards/auth.guard';
import { VerifiedEmailGuard } from './guards/verified-email.guard';
import { TokenUtils } from './utils/token.utils';
import { UserModule } from 'src/user/user.module';
@Module({
imports: [
forwardRef(() => UserModule),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: TypedConfigService) => ({
secret: config.get<AuthConfig>('auth')?.jwt.secret,
signOptions: {
expiresIn: config.get<AuthConfig>('auth')?.jwt.expiresIn,
},
}),
}),
],
providers: [
AuthResolver,
AuthService,
JwtStrategy,
PasswordService,
RolesGuard,
GqlAuthGuard,
VerifiedEmailGuard,
TokenUtils,
{
provide: APP_GUARD,
useClass: GqlAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
{
provide: TypedConfigService,
useExisting: ConfigService,
},
],
exports: [AuthService, PasswordService, TokenUtils],
})
export class AuthModule {}
The APP_GUARD
providers make authentication required by default for all GraphQL operations. Routes need to be explicitly marked as @Public()
to bypass authentication.
Testing It Out
Start your app and try the flow:
- Signup: Creates an unverified user and prints a verification link
- Email Verification: Click the link (or paste token in GraphQL playground) to verify and auto-login
- Login: Regular login for verified users
- Protected Operations: Try accessing user data - should work once authenticated
- Logout: Clears the authentication cookie
The authentication state persists across browser sessions thanks to the secure HTTP-only cookies.
What I Learned
- Cookie security matters
- Error messages should be vague: Don't leak information about which emails exist in your system
- Email verification cleanup is essential
- GraphQL context is different: You need custom guards and extractors for GraphQL vs REST
Next Steps
This authentication system is production-ready, but there are some features you might want to add:
- Email service integration (SendGrid, AWS SES, etc.)
- Password reset functionality
- Rate limiting for login attempts
- Refresh tokens for longer sessions
- Account lockout after failed attempts
The foundation we've built makes adding these features much easier. The modular structure means you can extend the authentication system without breaking existing functionality.
And honestly? Once you get through the initial setup pain, this system is pretty solid. The HTTP-only cookies, email verification, and role-based access control handle most real-world authentication requirements.
Top comments (0)