DEV Community

Cover image for NestJS Authentication Tutorial - Part 1: Foundation & Setup
Rosa
Rosa

Posted on

NestJS Authentication Tutorial - Part 1: Foundation & Setup

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

Now run this command:

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

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',
  }),
);
Enter fullscreen mode Exit fullscreen mode
// 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),
  }),
);
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode
// src/config/typed-config.service.ts
import { ConfigService } from '@nestjs/config';
import { ConfigType } from './config.types';

export class TypedConfigService extends ConfigService<ConfigType> {}
Enter fullscreen mode Exit fullscreen mode

Why all this configuration complexity?

  1. Type safety: We get autocomplete and type checking for all config values
  2. Validation: The app won't start if required environment variables are missing
  3. Documentation: The Joi schema serves as documentation for what environment variables are needed
  4. 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
Enter fullscreen mode Exit fullscreen mode

Module structure

Generate the required modules using NestJS CLI:

nest generate resource user --no-spec
nest generate resource auth --no-spec
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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.

Continue to Part 2: JWT Authentication & Email Verification

Get the complete source code on GitHub

Top comments (0)