Session Plugin
Redis session management — "Spring Session for NestJS". A drop-in store for the session middleware you already run, plus the capabilities the Store contract cannot offer: a per-user device page, "log out everywhere (else)", seat limits, an absolute lifetime cap, and audit events.
Overview
The plugin deliberately splits responsibilities. The store layer implements both the express-session and @fastify/session store contracts over the RedisX driver — cookie crypto, session fixation defense, and ID rotation stay with the battle-tested middleware, and Passport, req.session, and the connect ecosystem keep working untouched. On top of the same Redis keys, SESSION_SERVICE maintains per-user and global indexes plus metadata, which is where the product features live.
| Challenge | With connect-redis | With the Session Plugin |
|---|---|---|
| "Which devices am I signed in on?" | Impossible (opaque keys) | getSessionsByUser() with IP, browser, last activity |
| "Log out everywhere else" | Impossible | revokeAllExcept(userId, currentSid) |
| Seat limits (B2B licensing, banking) | Impossible | maxSessionsPerUser + reject / evict-oldest |
| "Re-login every 12h regardless of activity" (PCI DSS / OWASP) | Impossible — middleware only does idle timeout | absoluteLifetimeMs |
| Session audit trail | Manual | onCreated / onRevoked / onExpiredByCap events + Prometheus counters |
Key Features
- Drop-in store —
toExpressStore()/toFastifyStore()adapters; migration fromconnect-redisis one line - Device page — sessions per user with metadata (IP, user agent,
createdAt,lastSeenAt) via the Passport-awareuserIdExtractor - Revocation —
revoke(sid),revokeAll(userId),revokeAllExcept(userId, currentSid) - Security policies — per-user seat limits (atomic
rejectorevict-oldest) and an absolute lifetime cap with TTL clamping - Observability — lifecycle event callbacks and Prometheus counters when
MetricsPluginis registered - Cluster-safe — payload+metadata share a hash tag for atomic Lua; index operations are single-key
Installation
npm install @nestjs-redisx/core @nestjs-redisx/session ioredis express-sessionnpm install @nestjs-redisx/core @nestjs-redisx/session ioredis @fastify/cookie @fastify/sessionexpress-session and @fastify/session are optional peer dependencies — install only the one you use.
Basic Configuration
import { Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-redisx/core';
import { SessionPlugin } from '@nestjs-redisx/session';
@Module({
imports: [
RedisModule.forRoot({
clients: {
host: 'localhost',
port: 6379,
},
plugins: [
new SessionPlugin({
keyPrefix: 'sess:', // Redis key namespace
defaultTtlMs: 86_400_000, // 1 day when the cookie has no maxAge
}),
],
}),
],
})
export class AppModule {}Wiring the Middleware
One line replaces connect-redis:
import { NestFactory } from '@nestjs/core';
import session from 'express-session';
import { SESSION_STORE, toExpressStore } from '@nestjs-redisx/session';
import type { ISessionStore } from '@nestjs-redisx/session';
import { AppModule } from './types';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
// One line replaces connect-redis — passport and req.session keep working.
const store = app.get<ISessionStore>(SESSION_STORE);
app.use(
session({
secret: process.env.SESSION_SECRET ?? 'change-me',
resave: false,
saveUninitialized: false,
rolling: true, // slide the TTL on every request
cookie: { maxAge: 3600_000, httpOnly: true },
store: await toExpressStore(store),
}),
);
await app.listen(3000);
}
void bootstrap();The Device Page
import { Controller, Get, Inject, Req } from '@nestjs/common';
import { SESSION_SERVICE } from '@nestjs-redisx/session';
import type { ISessionService } from '@nestjs-redisx/session';
import { AppSession } from './types';
interface SessionRequest {
sessionID: string;
session: { passport?: { user?: string } };
}
@Controller('account')
export class DevicePageController {
constructor(@Inject(SESSION_SERVICE) private readonly sessions: ISessionService<AppSession>) {}
// GitHub-style "Sessions" page: every device with IP, browser, and activity.
@Get('sessions')
async devicePage(@Req() req: SessionRequest) {
const userId = req.session.passport?.user;
const devices = await this.sessions.getSessionsByUser(userId!);
return devices.map((device) => ({
id: device.id,
current: device.id === req.sessionID,
ip: device.metadata?.ip,
userAgent: device.metadata?.userAgent,
signedInAt: device.metadata?.createdAt,
lastActiveAt: device.metadata?.lastSeenAt,
}));
}
}Log Out Everywhere Else
import { Injectable, Inject } from '@nestjs/common';
import { SESSION_SERVICE } from '@nestjs-redisx/session';
import type { ISessionService } from '@nestjs-redisx/session';
@Injectable()
export class SessionSecurityService {
constructor(@Inject(SESSION_SERVICE) private readonly sessions: ISessionService) {}
// The "log out everywhere else" button: keeps the clicking device signed in.
logoutOtherDevices(userId: string, currentSessionId: string): Promise<number> {
return this.sessions.revokeAllExcept(userId, currentSessionId);
}
// Password change / account compromise: terminate everything.
async onPasswordChanged(userId: string): Promise<number> {
return this.sessions.revokeAll(userId);
}
// Support/admin: terminate one specific session by ID.
async revokeSingle(sessionId: string): Promise<boolean> {
return this.sessions.revoke(sessionId);
}
// Live counters for dashboards.
async stats(userId: string): Promise<{ total: number; forUser: number }> {
return {
total: await this.sessions.count(),
forUser: await this.sessions.countByUser(userId),
};
}
}Next Steps
- Store Adapters — express/fastify wiring, TTL semantics, migration from connect-redis
- Configuration — all options with defaults
- Service API — the full
ISessionServicereference - Security Policies — seat limits and the absolute lifetime cap
- Monitoring — events and metrics