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
After a successful authentication, Google redirects the user back to our application at:
GET /auth/google/callback
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>
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>
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>
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:
- Reads the
sidcookie - Retrieves the corresponding session from Redis
- 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:
- A valid session exists
- The session belongs to an authenticated user
- 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
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
If the cookie exists, it may look like:
sid=abc123
2οΈβ£ Resolves the session from Redis
Using the cookie value, Express queries Redis:
sess:abc123
- 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 = { ... }
- 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.sessionexists, 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());
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());
This middleware depends on express-session having already executed.
Step-by-step behavior
- Checks that
req.sessionexists (it always does) - Looks for:
req.session.passport?.user;
Case A: User is logged in
If the value exists:
req.session.passport.user = userId;
Passport then:
- Reconstructs the user identifier
- Fetches the full user from the database
- Attaches it to:
req.user;
β The request becomes authenticated
Case B: User is NOT logged in
If the value does not exist:
req.session.passport === undefined;
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-sessionstores it in Redis - Redis persists across requests
- The browser keeps sending the
sidcookie
Timeline Example
Login request
GET /auth/google
Inside the controller:
req.login(user);
This stores the following in Redis:
sess:abc123 β { passport: { user: "42" } }
Subsequent request
POST /some-private-endpoint
Middleware execution:
-
express-sessionloadssess:abc123 -
passport.session()detectspassport.user - Passport reconstructs the user
-
req.useris 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();
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();
}
}
Passport Session Serializer
File: auth/serializers/session.serializer.ts
This serializer defines:
- What minimal data is stored in the session
- 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);
}
}
Register it in your module:
providers: [AuthService, GoogleStrategy, SessionSerializer];
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);
}
}
}
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();
}
}
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 };
}
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!';
}
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).
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.
β Github Repo
If this repository saved you time or effort, please β star it on GitHub.
Top comments (0)