Nest Authbeta

Session Storage

Pick between database, Redis, and memory session stores.

Sessions are persisted server-side. You pick the backend on session.storageType.

The three built-in stores

StoreSessionStorageTypeBest for
DatabaseDATABASE (default)Single-instance apps, small/medium scale, transactional consistency with the rest of your data
RedisREDISMulti-instance apps, horizontal scale, sub-ms read latency
MemoryMEMORYLocal development, tests, single-instance prototypes
import { NestAuthModule, SessionStorageType } from '@ackplus/nest-auth';
 
NestAuthModule.forRoot({
  appName: 'my-app',
  session: {
    storageType: SessionStorageType.REDIS,
    redis: {
      host: process.env.REDIS_HOST,
      port: 6379,
      keyPrefix: 'auth:',
      // Standard ioredis options pass through.
    },
  },
});

Database

The default. Sessions live in the nest_auth_sessions table. Every refresh, revoke, and lookup is one SQL query.

Pros: zero extra infrastructure, joins with the rest of your data, transactional with user updates, easy to inspect during debugging.

Cons: writes scale with login/refresh traffic. On high-traffic tenants with slidingExpiration: true, the table can become a write hot-spot — switch to Redis.

Redis

Sessions live as Redis keys under your configured prefix. Refresh tokens are looked up by key — sub-millisecond on a warm cluster.

Set up:

session: {
  storageType: SessionStorageType.REDIS,
  redis: {
    host: 'redis.internal',
    port: 6379,
    db: 0,
    keyPrefix: 'auth:',
    password: process.env.REDIS_PASSWORD,
    // ioredis options:
    // - sentinels / cluster / TLS, retryStrategy, offlineQueue, …
  },
}

Reusing your own Redis connection

Pass an existing ioredis client as redis.client and the library uses it instead of opening a second connection. One pool, one place to configure TLS, password, sentinel or cluster:

import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);   // your app's connection
 
NestAuthModule.forRoot({
  session: {
    storageType: SessionStorageType.REDIS,
    redis: { client: redis, keyPrefix: 'auth:' },
  },
});

When client is set, the other connection fields (url, host, port, password, tls, …) are ignored; keyPrefix and ttlSeconds still apply. Lifecycle stays with your app — the library never calls quit() on a client it did not create, so shutting down Nest will not close a connection the rest of your app is still using.

The reverse also holds, as of 2.18.0: a connection the library did open is closed when the Nest application shuts down. Before that it was left open, so each app instance leaked a socket that ioredis kept trying to reconnect — noticeable as ECONNRESET noise on teardown, and as connections piling up in a process that boots more than one app (test runs, workers).

User-state cache

The guard enforces three user fields on every authenticated request: isActive, mustChangePassword and emailVerifiedAt. Reading them from the database meant one SELECT per API call for data that almost never changes — so with the Redis session store they are cached too, and a warm authenticated request costs zero database queries.

session: {
  storageType: SessionStorageType.REDIS,
  redis: { url: process.env.REDIS_URL },
  userStateCache: {
    enabled: true,     // default: true on Redis, false on database/memory
    ttlSeconds: 300,   // default: 300 when shared, 5 when per-process
  },
}

Why the default depends on your store

A stale isActive is an authentication bypass — a deactivated account would keep working until the entry expired. So the default follows whether an invalidation can actually reach every instance:

Session storeCacheDefaultWhy
Redisshared by all instancesonInvalidating on one instance is seen by all of them.
Database / Memoryper Node processoffDeactivating on instance A would not reach instance B until the TTL expired.

On a database or memory store you can still opt in with enabled: true — a single-instance deployment loses nothing, and a multi-instance one accepts a staleness window of ttlSeconds on the instances that did not make the write.

How entries are invalidated

Writes that go through TypeORM are handled for you. An entity subscriber on NestAuthUser drops the account's entry on every ORM insert, update or remove, so both UserService calls and a bare user.save() are covered — a deactivation takes effect on the very next request, on every instance.

Writes TypeORM cannot see are not, and must invalidate explicitly:

  • repository.update(...) and any other bulk UPDATE (TypeORM runs no subscriber for these)
  • raw SQL and migrations
  • another application writing the same database
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NestAuthCacheService } from '@ackplus/nest-auth';
 
@Injectable()
export class ComplianceService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly nestAuthCache: NestAuthCacheService,
  ) {}
 
  async banUser(userId: string) {
    await this.dataSource.query(
      'UPDATE nest_auth_users SET "isActive" = false WHERE id = $1',
      [userId],
    );
    // Without this the account keeps working until ttlSeconds elapses.
    await this.nestAuthCache.invalidateUser(userId);
  }
}

invalidateUsers(userIds) does the same for a batch. Every invalidation emits NestAuthEvents.USER_STATE_INVALIDATED with { userId }.

The TTL is a backstop for those cases, not the mechanism — it bounds how long a missed invalidation can hurt you. Lower it if you write user state outside the ORM and cannot call invalidateUser().

Never cached. Two things always go to their source: an account that does not exist (so a deleted user is refused immediately, never from a stale entry), and the session itself — that is always a session-store read, which is why a store outage still fails closed.

Set userStateCache: { enabled: false } to restore the pre-2.18.0 behaviour of reading the user row on every request.

What happens if Redis goes down

The guard fails closed. A store outage is infrastructure failure, not a failed sign-in, so it is never read as "no session":

Response
Route with required auth503 · SESSION_STORE_UNAVAILABLE
Route with optional auth, caller presents a token503 · SESSION_STORE_UNAVAILABLE
Route with optional auth, no token at allnormal anonymous response

503 rather than 401 is deliberate. A 401 tells clients to sign the user out, so a brief Redis blip would log out your entire user base. 503 says retry. Handle it in your client as a transient error, not an auth failure.

The outage is logged once when it starts and once when it recovers, not once per request.

Security note. Before 2.16.0 an optional-auth route silently fell back to anonymous during a Redis outage — the request was served with no user attached. Any such route that also carries @NestAuthRoles or @NestAuthPermissions was effectively open for the duration of the outage. Upgrading closes that.

Pros: horizontal scale, fast, automatic key expiry, no DB write pressure, and — with the user-state cache above — no database read on an authenticated request at all.

Cons: another service to operate. If Redis goes down, authenticated requests are refused with 503 until it returns — plan for failover.

See Scaling for the full RedisSessionOptions reference and recommended settings (sentinel / cluster / retry strategy / offline queue).

Memory

Sessions live in a Map inside the Node process. Lost on restart. Lost when you scale to a second instance.

Use it for tests and local dev. Never in production, even single-instance — a deploy or crash logs every user out.

When sessions get cleaned up

TriggerWhat happens
User logs outTheir session row/key is deleted
User logs out of all devicesAll their session rows/keys are deleted
Refresh token expiresNext refresh attempt errors; client logs out
maxSessionsPerUser exceededOldest session evicted on next login
Password reset / changeAll sessions for the user are revoked

For DB-backed sessions on long retention, run a periodic cleanup of expired rows — Redis handles this automatically via key TTL.

Custom store

The three built-ins (database, Redis, memory) cover most needs. For anything else — DynamoDB, Cloudflare KV, Mongo, a bespoke Redis-cluster wrapper — implement the SessionStore interface (or extend BaseSessionRepository for the shared expiry/helper logic) and pass it via session.store. It overrides storageType.

import { BaseSessionRepository, NestAuthModule } from '@ackplus/nest-auth';
 
class DynamoSessionStore extends BaseSessionRepository {
  constructor(private readonly db: DynamoClient) { super(); }
  async create(session) { /* … */ }
  async findById(id) { /* … */ }
  // …implement the rest of the SessionStore methods
}
 
NestAuthModule.forRoot({
  session: { store: new DynamoSessionStore(myDynamoClient) },
});

store accepts a ready instance or a factory for async setup:

session: { store: async () => MyKvSessionStore.connect(env.KV_URL) }

The SessionStore interface is small (create, findById, findByUserId, findActiveByUserId, update, delete, deleteByUserId, deleteExpired, countActiveByUserId, updateLastActive). You control your store's own dependencies — nest-auth just calls these methods for every session operation.

On this page