Introduction
Session-based authentication is everywhere — yet many developers use it daily without fully understanding how it actually works.
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 4-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.
