This is part 1 of a 2-part series on building production-ready authentication in NestJS. In this part, we'll set up the project foundation, database, and user management. Part 2 will cover JWT authentication, email verification, and role-based access control.
So you want to add auth to your NestJS app? Cool. Fair warning though - this is one of those features that looks simple from the outside but honestly, it was more painful than I expected. Sure, there are tons of tutorials out there, but most of them skip the production-ready parts or gloss over the security considerations that actually matter.
After wrestling with JWT tokens, password hashing, and role-based access for longer than I'd like to admit, I finally got something solid working. Figured I'd share what I learned – maybe it'll save someone else the headache I went through.
What I Actually Built
The system handles the usual suspects:
- User registration and login
- JWT tokens stored in HTTP-only cookies (more on why later)
- Password updates that don't suck
- Role-based permissions
- GraphQL API because REST felt overkill for this project
- PostgreSQL because I trust it more than MongoDB for auth data
Setting Up the Project
First things first – create a new NestJS project:
npm i -g @nestjs/cli
nest new nestjs-auth
cd nestjs-auth
Now for the dependencies. I learned the hard way that getting the versions right matters:
# Core GraphQL stuff
npm install @nestjs/graphql @nestjs/apollo apollo-server-express graphql
# Database and auth libraries
npm install @nestjs/typeorm typeorm pg @nestjs/jwt @nestjs/passport passport passport-jwt passport-local
# Utilities I actually use
npm install bcryptjs cookie-parser class-validator class-transformer joi
# Dev dependencies
npm install -D @types/bcryptjs @types/cookie-parser @types/passport-jwt @types/passport-local
Main App Configuration
Here's my main.ts
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// This validation pipe has saved me from so many bugs
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
// Essential for HTTP-only cookies
app.use(cookieParser());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
The ValidationPipe is clutch here. Trust me on this one - you don't want random fields sneaking into your database.
Database Setup
I use Docker for local development because it keeps the database isolated and makes it easy for others to run the project. Create a docker-compose.yml
:
version: '3.8'
services:
postgres:
image: postgres:16
container_name: nestjs_auth_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nestjs_auth_db
ports:
- '5433:5432' # Using 5433 to avoid conflicts
volumes:
- nestjs_auth_db:/var/lib/postgresql/data
restart: unless-stopped
volumes:
nestjs_auth_db:
Now run this command:
docker-compose up -d
Configuration (Don't Hardcode Secrets, Please)
Let's set up proper configuration to avoid environment variable headaches later.
// src/config/auth.config.ts
import { registerAs } from '@nestjs/config';
export interface AuthConfig {
jwt: {
secret: string;
expiresIn: string;
cookieExpiresIn: number;
};
nodeEnv: string;
}
export const authConfig = registerAs(
'auth',
(): AuthConfig => ({
jwt: {
secret: process.env.JWT_SECRET as string,
expiresIn: process.env.JWT_EXPIRES_IN ?? '60m',
cookieExpiresIn: parseInt(process.env.JWT_COOKIE_EXPIRES_IN || '90', 10),
},
nodeEnv: process.env.NODE_ENV || 'development',
}),
);
// src/config/database.config.ts
import { registerAs } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const typeOrmConfig = registerAs(
'database',
(): TypeOrmModuleOptions => ({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5433'),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
synchronize: Boolean(process.env.DB_SYNC ?? false),
}),
);
Important note about synchronize: I only enable this in development. In production, you should manage database migrations properly. Synchronize will automatically create/update database tables based on your entities, which can be dangerous in production.
// src/config/config.types.ts
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as Joi from 'joi';
import { AuthConfig } from './auth.config';
export interface ConfigType {
database: TypeOrmModuleOptions;
auth: AuthConfig;
}
export const appConfigSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
DB_USER: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_DATABASE: Joi.string().required(),
DB_SYNC: Joi.number().valid(0, 1).required(),
JWT_SECRET: Joi.string().required(),
JWT_EXPIRES_IN: Joi.string().required(),
JWT_COOKIE_EXPIRES_IN: Joi.number().default(90),
});
// src/config/typed-config.service.ts
import { ConfigService } from '@nestjs/config';
import { ConfigType } from './config.types';
export class TypedConfigService extends ConfigService<ConfigType> {}
Why all this configuration complexity?
- Type safety: We get autocomplete and type checking for all config values
- Validation: The app won't start if required environment variables are missing
- Documentation: The Joi schema serves as documentation for what environment variables are needed
- Defaults: We can provide sensible defaults for optional values
Create your .env file:
# Database
DB_HOST=localhost
DB_PORT=5433
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=nestjs_auth_db
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=
JWT_COOKIE_EXPIRES_IN=
NODE_ENV=development
Module structure
Generate the required modules using NestJS CLI:
nest generate resource user --no-spec
nest generate resource auth --no-spec
When prompted, select "GraphQL (code first)" and "No" for CRUD entry points since I'll build custom operations.
User entity and roles
The User entity is the foundation of the authentication system. Here's how I structure it:
// src/user/role.enum.ts
export enum Role {
USER = 'user',
ADMIN = 'admin',
}
// src/user/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Role } from './role.enum';
@ObjectType()
@Entity()
export class User {
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
name: string;
@Field()
@Column({ unique: true })
email: string;
// No @Field() decorator - this should NEVER be exposed in GraphQL
@Column()
password: string;
@Field(() => [String])
@Column('text', { array: true, default: [Role.USER] })
roles: Role[];
@Field()
@Column({ default: false })
isEmailVerified: boolean;
@Field()
@Column({ nullable: true })
emailVerificationExpires: Date;
@Field()
@CreateDateColumn()
createdAt: Date;
}
Few things to note:
- UUID primary keys because auto-incrementing IDs are predictable
- Password field has no GraphQL decorator
- Using PostgreSQL arrays for roles - simpler than a separate table
- Email verification with expiry dates
Application module configuration
Update your app.module.ts
to wire everything together:
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AppService } from './app.service';
import { AppController } from './app.controller';
import { TypedConfigService } from './config/typed-config.service';
import { typeOrmConfig } from './config/database.config';
import { Request } from 'express';
import { authConfig } from './config/auth.config';
import { appConfigSchema } from './config/config.types';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';
import { User } from './user/user.entity';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: TypedConfigService) => ({
...(await configService.get('database')),
entities: [User],
}),
}),
ConfigModule.forRoot({
isGlobal: true,
load: [typeOrmConfig, authConfig],
validationSchema: appConfigSchema,
validationOptions: {
abortEarly: true,
},
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
graphiql: true,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
context: ({ req, res }: { req: Request; res: Response }) => ({
req,
res,
}), // We'll need this for cookies
}),
UserModule,
AuthModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: TypedConfigService,
useExisting: ConfigService,
},
],
})
export class AppModule {}
Password Service
// src/auth/password.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class PasswordService {
private readonly SALT_ROUNDS = 10;
public async hash(password: string): Promise<string> {
return await bcrypt.hash(password, this.SALT_ROUNDS);
}
public async verify(
plainPassword: string,
hashedPassword: string,
): Promise<boolean> {
return await bcrypt.compare(plainPassword, hashedPassword);
}
}
I use 10 salt rounds as it provides a good balance between security and performance. Higher values would be more secure but significantly slower.
User service and DTOs
Before building the UserService, I need to define the data transfer objects:
// src/user/dto/create-user.input.ts
import { InputType, Field } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
@InputType()
export class CreateUserInput {
@Field()
@IsNotEmpty()
@IsString()
name: string;
@Field()
@IsNotEmpty()
@IsEmail()
email: string;
@Field()
@IsNotEmpty()
@IsString()
@MinLength(8)
password: string;
@Field({ defaultValue: false })
isEmailVerified?: boolean;
@Field({ nullable: true })
emailVerificationExpires?: Date;
}
// src/user/dto/update-email-verification.input.ts
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class UpdateEmailVerificationInput {
@Field()
userId: string;
@Field()
isEmailVerified: boolean;
@Field(() => Date, { nullable: true })
emailVerificationExpires: Date | null;
}
User Service
Now for the actual business logic:
// src/user/user.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { CreateUserInput } from './dto/create-user.input';
import { PasswordService } from '../auth/password.service';
import { UpdateEmailVerificationInput } from './dto/update-email-verification.input';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
private passwordService: PasswordService,
) {}
async findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findById(id: string): Promise<User> {
const user = await this.usersRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async findByEmail(email: string): Promise<User | undefined> {
const user = await this.usersRepository.findOne({ where: { email } });
return user ?? undefined;
}
async create(createUserData: CreateUserInput): Promise<User> {
// Hash the password before creating the user
if (createUserData.password) {
createUserData.password = await this.passwordService.hash(
createUserData.password,
);
}
const user = this.usersRepository.create(createUserData);
return await this.usersRepository.save(user);
}
async updateEmailVerification(
input: UpdateEmailVerificationInput,
): Promise<User> {
const user = await this.findById(input.userId);
user.isEmailVerified = input.isEmailVerified;
user.emailVerificationExpires = input.emailVerificationExpires;
return this.usersRepository.save(user);
}
async update(id: string, updateData: Partial<User>): Promise<User> {
const user = await this.findById(id);
// Hash password if it is being updated
if (updateData.password) {
updateData.password = await this.passwordService.hash(
updateData.password,
);
}
Object.assign(user, updateData);
return this.usersRepository.save(user);
}
async remove(id: string): Promise<User> {
const user = await this.findById(id);
await this.usersRepository.remove(user);
return user;
}
}
The key thing here is that passwords get hashed automatically on create and update. This way you never accidentally store a plaintext password.
User Module
// src/user/user.module.ts
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
import { User } from './user.entity';
import { ConfigService } from '@nestjs/config';
import { TypedConfigService } from 'src/config/typed-config.service';
import { AuthModule } from 'src/auth/auth.module';
import { PasswordService } from 'src/auth/password.service';
@Module({
imports: [TypeOrmModule.forFeature([User]), forwardRef(() => AuthModule)],
providers: [
UserService,
UserResolver,
PasswordService,
{
provide: TypedConfigService,
useExisting: ConfigService,
},
],
exports: [UserService, PasswordService],
})
export class UserModule {}
The forwardRef() is there because of circular dependencies – UserModule needs stuff from AuthModule and vice versa. It's annoying but necessary.
What's Next
So far we've got:
- A solid foundation with proper database setup
- User entity that doesn't leak passwords everywhere
- Password hashing
- User service with all the CRUD operations we need
- Type-safe configuration
In the next part, I'll cover the actual authentication flow – user registration, email verification, login with JWTs, and role-based access control. The foundation we've built here makes all of that much cleaner to implement.
Top comments (0)