DEV Community

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

Posted on

Building Secure Session-Based Authentication in NestJS - Part 2

PART 2 - Sessions

This article continues the authentication series and focuses on how sessions work internally, how they are restored on every request, and how to secure them properly using Redis, Passport, and session rotation.


Sessions Overview

Imagine a user wants to change their password in our application.

Before doing so, they must authenticate.

The authentication flow begins when the user initiates:

GET /auth/google
Enter fullscreen mode Exit fullscreen mode

After a successful authentication, Google redirects the user back to our application at:

GET /auth/google/callback
Enter fullscreen mode Exit fullscreen mode

At this point, the user is considered logged in.

Later on, the same user sends another requestβ€”this time to change their password.

When the application receives this request, a critical question arises:

How can the server be sure this request comes from the same authenticated user, and not from someone else impersonating them?

HTTP requests are stateless and independent by nature. Each request is processed in isolation, and without a persistent mechanism, the server has no memory of who authenticated previously.

This is exactly where sessions become essential.

By creating a server-side session during login and associating it with the user, the application can reliably identify that user across subsequent requests. Every protected actionβ€”such as changing a passwordβ€”can then validate the session and ensure the request truly belongs to the authenticated user.


Desired Authentication Flow

Once the user successfully logs in, the server creates a new authenticated session.

On the server side, this session is stored in Redis. Conceptually, Redis works like a dictionary: each entry represents a user and maps to one or more active sessions belonging to that user.

For a first login, the application creates a Redis entry with the key:

user_session:<user_id>
Enter fullscreen mode Exit fullscreen mode

Inside this entry, the server stores the identifiers of all sessions associated with that user. Each individual session is stored using the format:

sess:<session_id>
Enter fullscreen mode Exit fullscreen mode

This design allows a single user to maintain multiple active sessions at the same timeβ€”for example, across different browsers or devices.

Once the session has been created and persisted in Redis, and just before sending the response back to the client, the server attaches a cookie to the response:

Set-Cookie: sid=<session_id>
Enter fullscreen mode Exit fullscreen mode

This cookie is HTTP-only and securely configured. From this moment onward, the browser automatically includes the sid cookie in every subsequent request.

When a new request arrives, the server:

  1. Reads the sid cookie
  2. Retrieves the corresponding session from Redis
  3. Reconstructs the authenticated context for that request

Thanks to this mechanism, the server can consistently identify the same user across multiple, independent HTTP requests.

As a result, when the user later performs a sensitive actionβ€”such as changing a password or performing a transferβ€”the server verifies that:

  1. A valid session exists
  2. The session belongs to an authenticated user
  3. The session has not been revoked or expired

Only if all these checks pass does the application allow the operation to proceed.


Middleware Chain

Before looking at code, it is crucial to understand how middleware execution works.

For every incoming HTTP request, Express / NestJS executes middleware in the exact order they are registered:

Request
  ↓
express-session
  ↓
passport.initialize()
  ↓
passport.session()
  ↓
Controller / Route Handler
Enter fullscreen mode Exit fullscreen mode

Each middleware has a very specific responsibility, and understanding this order is fundamental.


1. express-session

Responsibility

  • Load the session associated with the request (if any)
  • Provide state continuity across requests
  • Attach req.session

1️⃣ Reads the session ID from cookies

Express looks for a cookie named:

sid
Enter fullscreen mode Exit fullscreen mode

If the cookie exists, it may look like:

sid=abc123
Enter fullscreen mode Exit fullscreen mode

2️⃣ Resolves the session from Redis

Using the cookie value, Express queries Redis:

sess:abc123
Enter fullscreen mode Exit fullscreen mode
  • If found β†’ session data is loaded
  • If not found β†’ the session is treated as empty

3️⃣ Attaches req.session

Regardless of whether the session existed, Express always attaches a session object:

req.session = { ... }
Enter fullscreen mode Exit fullscreen mode
  • Existing session β†’ contains stored data
  • Missing session β†’ an empty session object is created

⚠️ Important

  • This does not authenticate the user
  • It only provides state continuity

Why no error if the cookie is missing?

Sessions are optional.

Anonymous users must be able to access endpoints such as login, signup, and OAuth initiation.

Therefore:

  • No cookie β†’ no error
  • req.session exists, but is empty

2. passport.initialize()

Responsibility

  • Prepare the request object to support authentication
  • Expose Passport helper methods

File

main.ts

app.use(passport.initialize());
Enter fullscreen mode Exit fullscreen mode

This middleware decorates req with methods such as:

  • req.login()
  • req.logout()
  • req.isAuthenticated()

What it does NOT do

  • ❌ Does not read sessions
  • ❌ Does not authenticate users
  • ❌ Does not restore users

Think of it as:

β€œEnable authentication capabilities, but do not authenticate yet.”


3. passport.session()

Responsibility

  • Restore the authenticated user from the session (if present)

File

main.ts

app.use(passport.session());
Enter fullscreen mode Exit fullscreen mode

This middleware depends on express-session having already executed.

Step-by-step behavior

  1. Checks that req.session exists (it always does)
  2. Looks for:
req.session.passport?.user;
Enter fullscreen mode Exit fullscreen mode

Case A: User is logged in

If the value exists:

req.session.passport.user = userId;
Enter fullscreen mode Exit fullscreen mode

Passport then:

  1. Reconstructs the user identifier
  2. Fetches the full user from the database
  3. Attaches it to:
req.user;
Enter fullscreen mode Exit fullscreen mode

βœ… The request becomes authenticated


Case B: User is NOT logged in

If the value does not exist:

req.session.passport === undefined;
Enter fullscreen mode Exit fullscreen mode

Then:

  • Passport does nothing
  • The request continues as anonymous
  • req.user === undefined

βœ… This is expected behavior


Important clarification

No middleware automatically sets req.session.passport.user.

This value is created only when req.login() is called, usually during:

  • OAuth callbacks (/auth/google/callback)
  • Username/password login endpoints

It persists because:

  • express-session stores it in Redis
  • Redis persists across requests
  • The browser keeps sending the sid cookie

Timeline Example

Login request

GET /auth/google
Enter fullscreen mode Exit fullscreen mode

Inside the controller:

req.login(user);
Enter fullscreen mode Exit fullscreen mode

This stores the following in Redis:

sess:abc123 β†’ { passport: { user: "42" } }
Enter fullscreen mode Exit fullscreen mode

Subsequent request

POST /some-private-endpoint
Enter fullscreen mode Exit fullscreen mode

Middleware execution:

  1. express-session loads sess:abc123
  2. passport.session() detects passport.user
  3. Passport reconstructs the user
  4. req.user is populated

πŸ‘‰ Same user identity, different HTTP request.


Middleware Setup

File: main.ts

This is where all session-related middleware is registered.

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { envs } from "./config/envs";
import { REDIS_CLIENT } from "./redis/redis.constants";
import { RedisStore } from "connect-redis";
import session from "express-session";
import passport from "passport";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const redisClient = app.get(REDIS_CLIENT);

  app.use(
    session({
      store: new RedisStore({
        client: redisClient,
        prefix: "sess:",
      }),
      name: "sid",
      secret: envs.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      rolling: true,
      cookie: {
        httpOnly: true,
        secure: envs.NODE_ENV === "production",
        sameSite: "lax",
        maxAge: 1000 * 60 * 60,
      },
    })
  );

  app.use(passport.initialize());
  app.use(passport.session());

  await app.listen(envs.PORT ?? 3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Session Authentication Guard

File: guards/session-authentication.guard.ts

This guard protects private endpoints by ensuring a request is authenticated.

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";

@Injectable()
export class SessionAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    return req.isAuthenticated();
  }
}
Enter fullscreen mode Exit fullscreen mode

Passport Session Serializer

File: auth/serializers/session.serializer.ts

This serializer defines:

  1. What minimal data is stored in the session
  2. How the user is reconstructed on future requests
import { PassportSerializer } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import { AuthService } from "../auth.service";
import { User } from "../entities/user.entity";

@Injectable()
export class SessionSerializer extends PassportSerializer {
  constructor(private readonly authService: AuthService) {
    super();
  }

  serializeUser(user: User, done: Function) {
    done(null, user.id);
  }

  async deserializeUser(userId: string, done: Function) {
    const user = await this.authService.findById(userId);
    done(null, user);
  }
}
Enter fullscreen mode Exit fullscreen mode

Register it in your module:

providers: [AuthService, GoogleStrategy, SessionSerializer];
Enter fullscreen mode Exit fullscreen mode

Session Rotation

Responsibility

  • Prevent session fixation
  • Invalidate old session IDs
  • Preserve authenticated user context

File: redis/session-rotation.service.ts

import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { Request } from "express";
import { SessionService } from "./session.service";
import { User } from "src/auth/entities/user.entity";

@Injectable()
export class SessionRotationService {
  constructor(private readonly sessionService: SessionService) {}

  async rotate(req: Request, user: User): Promise<void> {
    if (!req.session) {
      throw new InternalServerErrorException("Session not initialized");
    }

    const oldSessionId = req.sessionID;

    await new Promise<void>((resolve, reject) =>
      req.session.regenerate((err) => (err ? reject(err) : resolve()))
    );

    await new Promise<void>((resolve, reject) =>
      req.login(user, (err) => (err ? reject(err) : resolve()))
    );

    await this.sessionService.registerSession(user.id, req.sessionID);

    if (oldSessionId) {
      await this.sessionService.unregisterSession(user.id, oldSessionId);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Session Service

File: redis/session.service.ts

This service encapsulates all direct interaction with Redis sessions.

import { Inject, Injectable } from "@nestjs/common";
import type { RedisClientType } from "redis";
import { REDIS_CLIENT } from "../redis.constants";

@Injectable()
export class SessionService {
  constructor(
    @Inject(REDIS_CLIENT)
    private readonly redis: RedisClientType
  ) {}

  async registerSession(userId: string, sessionId: string) {
    await this.redis.sAdd(`user_sessions:${userId}`, sessionId);
  }

  async unregisterSession(userId: string, sessionId: string) {
    const pipeline = this.redis.multi();

    pipeline.del(`sess:${sessionId}`);
    pipeline.sRem(`user_sessions:${userId}`, sessionId);

    await pipeline.exec();
  }

  async revokeAllSessions(userId: string) {
    const key = `user_sessions:${userId}`;
    const sessionIds = await this.redis.sMembers(key);

    const pipeline = this.redis.multi();

    for (const sid of sessionIds) {
      pipeline.del(`sess:${sid}`);
    }

    pipeline.del(key);
    await pipeline.exec();
  }
}
Enter fullscreen mode Exit fullscreen mode

When to Rotate Sessions

βœ… Recommended moments

  • After login
  • After re-authentication (2FA, password confirmation)
  • After privilege or role changes
  • After sensitive operations

❌ When you should NOT rotate sessions

  • On every request
  • On read-only operations
  • On regular business actions

Rotate sessions when the security context changes, not when normal business logic is executed.


Example: Google Callback

File: auth/auth.controller.ts

@Get('google/callback')
@UseGuards(GoogleOAuthGuard)
async googleCallback(@Req() req: Request, @GetUser() user: OAuthUser) {
  const authUser = await this.authService.findOrCreate(user);
  await this.sessionRotationService.rotate(req, authUser);
  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

Testing a Protected Endpoint

File: auth/auth.controller.ts

// just for testing purposes, this is a GET endpoint in order to be easily
// called from browser, but it should be PATCH in-practice
@Get('users/password')
@UseGuards(SessionAuthenticationGuard)
async changePassword() {
  return 'Password changed!';
}
Enter fullscreen mode Exit fullscreen mode

If accessed without authentication, this endpoint responds with HTTP 403 Forbidden.


We can run our app and open a new browser tab, and login as usual via doing a GET request to /auth/google. Once we are logged, we can open a new tab (or just replacing URL in the current one) and access to /auth/users/password to verify that we have proper access to that private endpoint (because we are authenticated).

success

Note that if we open a new private tab and try to access to the private endpoint, we get an HTTP Status Code 403 as we are not logged.

error

⭐ Github Repo

If this repository saved you time or effort, please ⭐ star it on GitHub.

πŸ“š Article Series

Top comments (0)