DEV Community

Cover image for Building Secure Session-Based Authentication in NestJS - Part 1
juanpeyrot
juanpeyrot

Posted on • Edited on

Building Secure Session-Based Authentication in NestJS - Part 1

Introduction

Session-based authentication is everywhere — yet many developers rely on it daily without fully understanding how it actually works under the hood.

In this series, we will build a real, production-style session-based authentication system using NestJS, Passport, Redis, and HTTP cookies. Along the way, we will demystify what happens on every request:

  • How sessions are created
  • Where they are stored
  • How they are validated
  • How session rotation works
  • How sessions are revoked

No magic. No hidden abstractions.

This article is Part 1 of a 3-part series. By the end of the series, we will have a complete, working application.


Prerequisites

Before starting, make sure you have the following installed:


PART 1 — Project Setup

Dependencies & Environment Variables

Project initialization

First, create a new NestJS project:

nest new nest-session
Enter fullscreen mode Exit fullscreen mode

I will be using pnpm for this project, but you can use npm or yarn if you prefer.

Install the required dependencies:

pnpm i express-session redis connect-redis pg joi dotenv @nestjs/typeorm typeorm @nestjs/passport passport
Enter fullscreen mode Exit fullscreen mode

And the development typings:

pnpm i -D @types/express-session @types/passport
Enter fullscreen mode Exit fullscreen mode

Environment configuration

We will centralize and validate all environment variables using Joi.

Create the following file:

config/envs.ts

import 'dotenv/config';

import * as joi from 'joi';

interface EnvVars {
  PORT: number;
  NODE_ENV: string;

  GOOGLE_CLIENT_ID: string;
  GOOGLE_CLIENT_SECRET: string;
  GOOGLE_CALLBACK_URL: string;

  POSTGRES_HOST: string;
  POSTGRES_PORT: number;
  POSTGRES_USER: string;
  POSTGRES_PASSWORD: string;
  POSTGRES_DB_NAME: string;

  REDIS_PASSWORD: string;
  REDIS_HOST: string;
  REDIS_PORT: number;
  REDIS_URI: string;

  SESSION_SECRET: string;
}

const envsSchema = joi
  .object({
    PORT: joi.number().required(),
    NODE_ENV: joi.string().required(),

    GOOGLE_CLIENT_ID: joi.string().required(),
    GOOGLE_CLIENT_SECRET: joi.string().required(),
    GOOGLE_CALLBACK_URL: joi.string().required(),

    POSTGRES_HOST: joi.string().required(),
    POSTGRES_PORT: joi.number().required(),
    POSTGRES_USER: joi.string().required(),
    POSTGRES_PASSWORD: joi.string().required(),
    POSTGRES_DB_NAME: joi.string().required(),

    REDIS_PASSWORD: joi.string().required(),
    REDIS_HOST: joi.string().required(),
    REDIS_PORT: joi.number().required(),
    REDIS_URI: joi.string().required(),

    SESSION_SECRET: joi.string().required(),
  })
  .unknown(true);

const { error, value } = envsSchema.validate(process.env);

if (error) {
  throw new Error(`Config validation error: ${error.message}`);
}

const envVars: EnvVars = value;

export const envs = {
  PORT: envVars.PORT,
  NODE_ENV: envVars.NODE_ENV,

  GOOGLE_CLIENT_ID: envVars.GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET: envVars.GOOGLE_CLIENT_SECRET,
  GOOGLE_CALLBACK_URL: envVars.GOOGLE_CALLBACK_URL,

  POSTGRES_HOST: envVars.POSTGRES_HOST,
  POSTGRES_PORT: envVars.POSTGRES_PORT,
  POSTGRES_USER: envVars.POSTGRES_USER,
  POSTGRES_PASSWORD: envVars.POSTGRES_PASSWORD,
  POSTGRES_DB_NAME: envVars.POSTGRES_DB_NAME,

  REDIS_PASSWORD: envVars.REDIS_PASSWORD,
  REDIS_HOST: envVars.REDIS_HOST,
  REDIS_PORT: envVars.REDIS_PORT,
  REDIS_URI: envVars.REDIS_URI,

  SESSION_SECRET: envVars.SESSION_SECRET,
};
Enter fullscreen mode Exit fullscreen mode

.env file

Make sure all required variables are defined in your .env file:

PORT=3000
NODE_ENV=development

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback

POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB_NAME=

REDIS_PASSWORD=
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URI=redis://localhost:6379

SESSION_SECRET=
Enter fullscreen mode Exit fullscreen mode

⚠️ Every variable must have a value, or the application will fail during startup.


OAuth provider (Google)

For this guide, we will use OAuth2 with Google as the authentication provider.

Create a new OAuth application and obtain your Client ID and Client Secret here:

https://console.cloud.google.com/

You can follow this series without OAuth by implementing traditional login and signup endpoints. The session concepts remain exactly the same.


Application entry point

Make sure your application uses the configured port:

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { envs } from './config/envs';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(envs.PORT ?? 3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Auth & Redis Setup

Generate the Auth module

nest g res auth --no-spec
Enter fullscreen mode Exit fullscreen mode

Select:

  • Transport layer: REST API
  • Generate CRUD endpoints: No

Configure the AppModule

app.module.ts

import { envs } from 'src/config/envs';
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    AuthModule,
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: envs.POSTGRES_HOST,
      port: envs.POSTGRES_PORT,
      username: envs.POSTGRES_USER,
      password: envs.POSTGRES_PASSWORD,
      database: envs.POSTGRES_DB_NAME,
      autoLoadEntities: true,
      synchronize: true, // ⚠️ Disable in production
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Redis Module

We will create a simple global Redis provider.

Create a new folder: src/redis

redis.constants.ts

export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
Enter fullscreen mode Exit fullscreen mode

redis.module.ts

import { Module } from '@nestjs/common';
import { createClient } from 'redis';
import { envs } from 'src/config/envs';
import { REDIS_CLIENT } from './redis.constants';

@Module({
  providers: [
    {
      provide: REDIS_CLIENT,
      useFactory: async () => {
        const client = createClient({
          url: envs.REDIS_URI,
          password: envs.REDIS_PASSWORD,
        });

        await client.connect();
        return client;
      },
    },
  ],
  exports: [REDIS_CLIENT],
})
export class RedisModule {}
Enter fullscreen mode Exit fullscreen mode

User Entity

Before finalizing the auth setup, let’s define how our users will be stored.

auth/entities/user.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('varchar', { unique: true })
  email: string;

  @Column('varchar')
  fullName: string;

  @Column('varchar')
  provider: 'google' | 'github';

  @Column('varchar')
  providerId: string;

  @Column('varchar', { nullable: true })
  picture?: string;

  @Column('timestamp', { default: () => 'CURRENT_TIMESTAMP' })
  createdAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

AuthModule

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { RedisModule } from 'src/redis/redis.module';

@Module({
  imports: [
    PassportModule.register({ session: true }),
    TypeOrmModule.forFeature([User]),
    RedisModule,
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Docker Setup

Create a docker-compose.yml file in the project root:

version: '3'

services:
  db:
    image: postgres:14.3
    restart: always
    ports:
      - '5432:5432'
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_DB: ${POSTGRES_DB_NAME}
    container_name: nest-session-postgres
    volumes:
      - ./postgres:/var/lib/postgresql/data

  redis:
    image: redis:7
    container_name: redis
    command: redis-server --requirepass "${REDIS_PASSWORD}"
    ports:
      - '${REDIS_PORT}:6379'
    volumes:
      - ./redis-data:/data
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

Start the services:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Running the Application

Start the NestJS server:

pnpm run start:dev
Enter fullscreen mode Exit fullscreen mode

At this point, your database schema should be created automatically.

You can verify it by connecting to Postgres using your favorite DB client (e.g. TablePlus) and confirming that the User table matches the entity definition.

user_table_correctly_loaded

In the next part, we’ll define our services, controllers, and guards, and explore how NestJS uses them to authenticate requests, manage session state, and protect routes.


Configuring OAuth2 (Google)

In this section, we will integrate Google OAuth2 using Passport. This will allow users to authenticate using their Google accounts while still relying on our session-based infrastructure.

Installing dependencies

First, install the Passport strategy for Google OAuth2:

pnpm i passport-google-oauth20
Enter fullscreen mode Exit fullscreen mode

OAuth user interface

We define a shared interface that represents users coming from any OAuth provider.

auth/interfaces/oauth.interfaces.ts

export interface OAuthUser {
  provider: 'google' | 'github';
  providerId: string;
  email: string;
  fullName: string;
  picture?: string;
}
Enter fullscreen mode Exit fullscreen mode

This interface acts as a contract between Passport strategies and our application logic.


Google OAuth strategy

Now we can implement the Google strategy. This class encapsulates all provider-specific authentication logic.

auth/strategies/google.strategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
  Strategy as GoogleStrategyBase,
  Profile as GoogleProfile,
} from 'passport-google-oauth20';
import { envs } from 'src/config/envs';
import { OAuthUser } from '../interfaces/oauth.interfaces';

@Injectable()
export class GoogleStrategy extends PassportStrategy(
  GoogleStrategyBase,
  'google',
) {
  constructor() {
    super({
      clientID: envs.GOOGLE_CLIENT_ID,
      clientSecret: envs.GOOGLE_CLIENT_SECRET,
      callbackURL: envs.GOOGLE_CALLBACK_URL,
      scope: ['email', 'profile'],
    });
  }

  async validate(
    _accessToken: string,
    _refreshToken: string,
    profile: GoogleProfile,
  ): Promise<OAuthUser> {
    const email = this.getPrimaryEmail(profile);

    if (!email) {
      throw new UnauthorizedException('Google account has no verified email');
    }

    return {
      provider: 'google',
      providerId: profile.id,
      email,
      fullName:
        `${profile.name?.givenName ?? ''} ${profile.name?.familyName ?? ''}`.trim(),
      picture: this.getProfilePicture(profile),
    };
  }

  private getPrimaryEmail(profile: GoogleProfile): string {
    const email = profile.emails?.[0];

    if (!email?.verified) {
      throw new UnauthorizedException(`Email ${email?.value} is not verified`);
    }

    return email.value;
  }

  private getProfilePicture(profile: GoogleProfile): string | undefined {
    return profile.photos?.[0]?.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Important: Every Passport strategy must be registered as a provider in the AuthModule.

@Module({
  imports: [/* ... */],
  controllers: [/* ... */],
  providers: [
    /* other providers */,
    GoogleStrategy, // ← mandatory
  ],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

OAuth Guard

The guard simply delegates execution to the Google strategy.

auth/guards/google-oauth.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleOAuthGuard extends AuthGuard('google') {}
Enter fullscreen mode Exit fullscreen mode

No additional logic is required here.


Controller, Decorator and Logic

To complete the OAuth2 flow, we need two endpoints:

  1. GET /auth/google

    Initiates the Google login flow.

  2. GET /auth/google/callback

    Handles the response sent by Google after successful authentication.

auth.controller.ts

import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { GoogleOAuthGuard } from './guards/google-oauth.guard';
import { GetUser } from './decorators/get-user.decorator';
import type { OAuthUser } from './interfaces/oauth.interfaces';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Get('google')
  @UseGuards(GoogleOAuthGuard)
  async googleAuth() {}

  @Get('google/callback')
  @UseGuards(GoogleOAuthGuard)
  async googleCallback(@GetUser() user: OAuthUser) {
    const authUser = await this.authService.findOrCreate(user);
    // we'll be introducing more logic later on
    return { success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom user decorator

To simplify access to the authenticated user, we implement a custom decorator.

auth/decorators/get-user.decorator.ts

import {
  createParamDecorator,
  ExecutionContext,
  InternalServerErrorException,
} from '@nestjs/common';
import { OAuthUser } from '../interfaces/oauth.interfaces';

export const GetUser = createParamDecorator(
  (data: keyof OAuthUser, ctx: ExecutionContext): OAuthUser => {
    const req = ctx.switchToHttp().getRequest();
    const user = req.user;

    if (!user) {
      throw new InternalServerErrorException('User not found in request');
    }

    return data ? user[data] : user;
  },
);
Enter fullscreen mode Exit fullscreen mode

Authentication service logic

Finally, the AuthService handles persistence and lookup logic.

auth.service.ts

import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { OAuthUser } from './interfaces/oauth.interfaces';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async findOrCreate(oauthUser: OAuthUser): Promise<User> {
    if (!oauthUser) {
      throw new BadRequestException('Unauthenticated');
    }

    const existingUser = await this.userRepository.findOne({
      where: { email: oauthUser.email },
    });

    if (existingUser) {
      return existingUser;
    }

    const user = this.userRepository.create(oauthUser);
    return this.userRepository.save(user);
  }

  async findById(id: string): Promise<User | null> {
    return this.userRepository.findOne({ where: { id } });
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point, Google OAuth2 is fully integrated.

You can test the flow by opening:

http://localhost:3000/auth/google
Enter fullscreen mode Exit fullscreen mode

After logging in with a Google account, you should see the user persisted in Postgres.

user_saved_in_db

In the next part, we will build on top of this foundation by introducing sessions, Redis-backed storage, and secure cookie handling.

📚 Article Series

Top comments (0)