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:
- Docker – https://www.docker.com/
- NestJS CLI – https://docs.nestjs.com/cli/overview
- TablePlus (optional) – https://tableplus.com/download/ (or any database client of your choice)
PART 1 — Project Setup
Dependencies & Environment Variables
Project initialization
First, create a new NestJS project:
nest new nest-session
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
And the development typings:
pnpm i -D @types/express-session @types/passport
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,
};
.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=
⚠️ 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();
Auth & Redis Setup
Generate the Auth module
nest g res auth --no-spec
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 {}
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');
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 {}
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;
}
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 {}
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
Start the services:
docker compose up -d
Running the Application
Start the NestJS server:
pnpm run start:dev
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.
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
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;
}
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;
}
}
⚠️ Important: Every Passport strategy must be registered as a provider in the AuthModule.
@Module({
imports: [/* ... */],
controllers: [/* ... */],
providers: [
/* other providers */,
GoogleStrategy, // ← mandatory
],
})
export class AuthModule {}
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') {}
No additional logic is required here.
Controller, Decorator and Logic
To complete the OAuth2 flow, we need two endpoints:
GET /auth/google
Initiates the Google login flow.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 };
}
}
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;
},
);
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 } });
}
}
At this point, Google OAuth2 is fully integrated.
You can test the flow by opening:
http://localhost:3000/auth/google
After logging in with a Google account, you should see the user persisted in Postgres.
In the next part, we will build on top of this foundation by introducing sessions, Redis-backed storage, and secure cookie handling.


Top comments (0)