Session-Based Authentication
Session-based authentication keeps the user’s identity on the server and hands the browser only an opaque session ID stored in a cookie. On each request, NestJS looks that ID up in a session store and rehydrates the user — there is no token to verify or expire client-side. This stateful model is the natural fit for classic server-rendered apps and gives you instant, server-controlled logout, at the cost of needing a shared store. This page wires up express-session, Passport’s session serialization, and a Redis store, then covers the CSRF risk that cookies introduce.
Installing the dependencies
Sessions build on the same Passport core you use for the local strategy, plus express-session and a store. For production you almost always want Redis rather than the default in-memory store.
npm install @nestjs/passport passport express-session connect-redis redis
npm install -D @types/express-session @types/passport
Configuring express-session
express-session is registered as Express middleware in main.ts, before Passport. It manages the signed session cookie and persists session data to the configured store. Set secret from an environment variable, disable resave/saveUninitialized so you only write sessions that actually hold data, and lock the cookie down with httpOnly, secure, and sameSite.
import { NestFactory } from '@nestjs/core';
import * as session from 'express-session';
import * as passport from 'passport';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24, // 24 hours
},
}),
);
app.use(passport.initialize());
app.use(passport.session());
await app.listen(3000);
}
bootstrap();
Warning: The default
MemoryStoreleaks memory and is not shared across processes — it is for development only. Always configure Redis (or another store) before going to production, or sessions will vanish on restart and break under clustering.
Serializing the user into the session
Passport sessions never store the whole user object in the session — only enough to look it up again. serializeUser decides what gets written (almost always just the user ID); deserializeUser runs on every authenticated request to turn that ID back into a full user attached to request.user. In NestJS you implement both by extending PassportSerializer.
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { UsersService } from '../users/users.service';
import { User } from '../users/user.entity';
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private readonly usersService: UsersService) {
super();
}
serializeUser(user: User, done: (err: Error | null, id?: number) => void) {
done(null, user.id); // only the id is stored in the session
}
async deserializeUser(
id: number,
done: (err: Error | null, user?: Omit<User, 'passwordHash'> | null) => void,
) {
try {
const user = await this.usersService.findById(id);
done(null, user ?? null);
} catch (err) {
done(err as Error);
}
}
}
Logging in and persisting the session
Reuse the local strategy to validate credentials, but configure the guard to start a session. AuthGuard('local') only authenticates — to write the session you must call Passport’s req.logIn() (or set session: true on the guard). Subclassing the guard lets you trigger super.logIn() so the serializer runs and the cookie is issued.
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LoginGuard extends AuthGuard('local') {
async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request); // serializes user -> writes session
return result;
}
}
The controller exposes login, a protected route, and logout. req.logOut() clears request.user; req.session.destroy() removes the server-side record so the session ID becomes useless even if the cookie lingers.
import {
Controller,
Get,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { LoginGuard } from './login.guard';
import { AuthenticatedGuard } from './authenticated.guard';
@Controller('auth')
export class AuthController {
@UseGuards(LoginGuard)
@Post('login')
login(@Req() req: Request) {
return req.user; // session cookie already set
}
@UseGuards(AuthenticatedGuard)
@Get('profile')
profile(@Req() req: Request) {
return req.user; // rehydrated by deserializeUser
}
@Post('logout')
logout(@Req() req: Request) {
req.logOut((err) => {
if (err) throw err;
});
req.session.destroy(() => undefined);
return { loggedOut: true };
}
}
The AuthenticatedGuard simply asks Passport whether a session exists:
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class AuthenticatedGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated();
}
}
Wiring up the module
Register the strategy, serializer, and guards as providers, and import PassportModule with session: true so Passport hooks into the session middleware.
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './local.strategy';
import { SessionSerializer } from './session.serializer';
import { UsersModule } from '../users/users.module';
@Module({
imports: [PassportModule.register({ session: true }), UsersModule],
controllers: [AuthController],
providers: [LocalStrategy, SessionSerializer],
})
export class AuthModule {}
Trying it out
curl with a cookie jar shows the session ID being issued on login and replayed on the protected route.
curl -s -c jar.txt -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"ada","password":"correct-horse"}'
curl -s -b jar.txt http://localhost:3000/auth/profile
Output:
{"id":1,"username":"ada"}
{"id":1,"username":"ada"}
Sessions vs. JWTs
The choice between the two comes down to where state lives and how much control you need over revocation.
| Concern | Sessions | JWTs |
|---|---|---|
| State | Server-side store | Stateless (self-contained) |
| Revocation | Instant (delete from store) | Hard (needs a denylist) |
| Scaling | Needs shared store (Redis) | No shared store required |
| Transport | Cookie (CSRF-prone) | Header (CSRF-safe) |
| Best for | Server-rendered web apps | APIs and mobile clients |
CSRF considerations
Because the session cookie is sent automatically by the browser, a malicious site can trigger authenticated state-changing requests — a CSRF attack. Setting sameSite: 'lax' (or 'strict') on the cookie blocks the common cross-site cases and is your first line of defense. For full coverage on browsers without SameSite support, add anti-CSRF tokens with a package like csrf-csrf and require the token on every mutating request.
Tip:
SameSite=Laxstill allows top-level GET navigations to carry the cookie, so never perform side effects onGET. Keep mutations onPOST/PUT/DELETEwhereLaxand CSRF tokens protect you.
Best Practices
- Always run a real session store (Redis) in production; the in-memory default loses sessions and cannot scale.
- Serialize only the user ID, then reload the fresh user in
deserializeUserso role and status changes take effect immediately. - Set
httpOnly,secure, andsameSiteon the cookie, and serve over HTTPS so the session ID cannot be stolen. - Call
session.destroy()on logout, not justlogOut(), to invalidate the record server-side. - Use
resave: falseandsaveUninitialized: falseto avoid writing empty sessions and racing concurrent requests. - Defend mutating routes with
SameSiteplus CSRF tokens, and keepGEThandlers side-effect free. - Rotate the session ID after privilege changes (login, role elevation) to prevent session fixation.