Skip to content

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.

ChallengeWith connect-redisWith the Session Plugin
"Which devices am I signed in on?"Impossible (opaque keys)getSessionsByUser() with IP, browser, last activity
"Log out everywhere else"ImpossiblerevokeAllExcept(userId, currentSid)
Seat limits (B2B licensing, banking)ImpossiblemaxSessionsPerUser + reject / evict-oldest
"Re-login every 12h regardless of activity" (PCI DSS / OWASP)Impossible — middleware only does idle timeoutabsoluteLifetimeMs
Session audit trailManualonCreated / onRevoked / onExpiredByCap events + Prometheus counters

Key Features ​

  • Drop-in store — toExpressStore() / toFastifyStore() adapters; migration from connect-redis is one line
  • Device page — sessions per user with metadata (IP, user agent, createdAt, lastSeenAt) via the Passport-aware userIdExtractor
  • Revocation — revoke(sid), revokeAll(userId), revokeAllExcept(userId, currentSid)
  • Security policies — per-user seat limits (atomic reject or evict-oldest) and an absolute lifetime cap with TTL clamping
  • Observability — lifecycle event callbacks and Prometheus counters when MetricsPlugin is registered
  • Cluster-safe — payload+metadata share a hash tag for atomic Lua; index operations are single-key

Installation ​

bash
npm install @nestjs-redisx/core @nestjs-redisx/session ioredis express-session
bash
npm install @nestjs-redisx/core @nestjs-redisx/session ioredis @fastify/cookie @fastify/session

express-session and @fastify/session are optional peer dependencies — install only the one you use.

Basic Configuration ​

typescript
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:

typescript
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 ​

typescript
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 ​

typescript
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 ​

Released under the MIT License.