Rate Limiting & Encryption

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), @nestjs/throttler 6.7.1 (published 2026-09-24, its peer range includes Nest 12), Node.js 22 and 24 LTS, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, with bcrypt 6.0 and argon2 0.45, all checked on 2026-10-02. The examples build with npm run build and npx tsc --noEmit, the HTTP behaviour shown is the same on the Express and Fastify adapters, and the status codes, headers and bodies shown are what the examples return. Two kinds of defence sit side by side in this page. Rate limiting caps how often a client may call an endpoint, which blunts brute-force logins and scraping in the Bookshelf application that grows across this section. Encryption and hashing protect data that must be stored: reversible encryption for values the application has to read back later, one-way hashing for passwords. The broader checklist is in Security Best Practices.

Rate limiting with @nestjs/throttler

@nestjs/throttler is the official rate-limiting package. It is a guard (see Guards) that counts requests per tracker (by default the client IP address) inside a time window and answers 429 Too Many Requests once the limit is exceeded. Because it is a guard it runs after middleware and before interceptors and pipes, and it is independent of the HTTP platform, as described in Request Lifecycle.

npm i @nestjs/throttler

Module and guard

Import ThrottlerModule with forRoot() or forRootAsync(), then bind ThrottlerGuard. The module only registers the configuration and the storage; nothing is limited until the guard is bound, either globally with APP_GUARD (as below), or per controller or handler with @UseGuards(ThrottlerGuard). Prefer APP_GUARD for the global case, because the guard needs the module options and the storage injected, which app.useGlobalGuards(new ThrottlerGuard()) cannot provide.

The example defines two named limits, a burst limit and a sustained limit, and reads the numbers from ConfigService:

Two named limits, configured asynchronously
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule, seconds } from '@nestjs/throttler';
import { BooksController, ReviewsController } from './books.controller.js';

@Module({
  imports: [
    ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
    ThrottlerModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        throttlers: [
          { name: 'short', ttl: seconds(1), limit: config.get<number>('THROTTLE_SHORT_LIMIT', 3) },
          { name: 'long', ttl: seconds(60), limit: config.get<number>('THROTTLE_LONG_LIMIT', 5) },
        ],
        errorMessage: 'Slow down: too many requests',
      }),
    }),
  ],
  controllers: [BooksController, ReviewsController],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class RateLimitingModule {}

Things to know about this shape:

  • ttl is in milliseconds (since @nestjs/throttler 5). The package exports the helpers seconds(), minutes(), hours(), days() and weeks(), which return milliseconds and make the values readable, so ttl: seconds(60) instead of ttl: 60000.

  • forRoot() accepts either an array of throttler definitions (the form used in the docs' multiple-definitions example) or an options object whose throttlers property holds that array. Use the object form as soon as you need storage, errorMessage, skipIf and other global options.

  • A throttler without a name is called default. Names matter because the decorators address throttlers by name and because each named throttler counts independently: a request must pass every throttler.

  • Throttlers are evaluated in the order of the array, and the first one that is exceeded makes the request fail. The environment values read through ConfigService arrive as strings when they come from the process environment, so validate and convert them (see Configuration); the get<number>() generic is a type hint, not a conversion.

What the client sees

With the module above, the fourth request within one second to GET /books is rejected:

curl -i http://localhost:3000/books
HTTP/1.1 200 OK
X-RateLimit-Limit-short: 3
X-RateLimit-Remaining-short: 2
X-RateLimit-Reset-short: 1
X-RateLimit-Limit-long: 5
X-RateLimit-Remaining-long: 4
X-RateLimit-Reset-long: 60
HTTP/1.1 429 Too Many Requests
Retry-After-short: 1
{ "statusCode": 429, "message": "Slow down: too many requests" }

Behaviour worth noting:

  • With named throttlers, every header carries the throttler’s name as a suffix (X-RateLimit-Limit-short). Only the unnamed default throttler produces the plain X-RateLimit-Limit header. Clients that look for Retry-After will not find it when the throttlers are named: the header is Retry-After-short or Retry-After-long. If your clients need the standard header, keep a single unnamed throttler or add the header in an exception filter.

  • The reset and retry values are in seconds even though the configuration is in milliseconds.

  • The default message is ThrottlerException: Too Many Requests; the errorMessage option (a string, or a function of the ExecutionContext and the limit details) replaces it, as shown.

  • A blocked request is counted: the limit + 1-th request is the first one rejected, and blockDuration (see below) decides how long the client stays rejected.

@Throttle and @SkipThrottle

@Throttle() overrides limit, ttl (and optionally blockDuration, getTracker and generateKey) for one handler or a whole controller. Since version 5 it takes an object keyed by throttler name; use default when the throttler is unnamed. @SkipThrottle() excludes a handler or controller, and takes an object of name: boolean pairs so a skip can be narrowed or reverted per throttler.

Overriding and skipping per route
import { Body, Controller, Get, Post } from '@nestjs/common';
import { SkipThrottle, Throttle } from '@nestjs/throttler';

@Controller('books')
export class BooksController {
  // Uses every named limit from the module (short + long).
  @Get()
  findAll() {
    return [{ id: 1, title: 'Dune' }];
  }

  // Overrides only the "short" limit: 2 requests per 10 seconds.
  @Throttle({ short: { limit: 2, ttl: 10_000 } })
  @Post('search')
  search(@Body() body: { q: string }) {
    return { q: body.q, hits: 1 };
  }

  // Skips both named throttlers. A bare @SkipThrottle() only skips the one named "default".
  @SkipThrottle({ short: true, long: true })
  @Get('health')
  health() {
    return { status: 'ok' };
  }
}

// Skips both throttlers on the class, except that "long" is switched back on for one route.
@SkipThrottle({ short: true, long: true })
@Controller('reviews')
export class ReviewsController {
  @SkipThrottle({ long: false })
  @Get()
  findAll() {
    return [];
  }

  @Get('latest')
  latest() {
    return [];
  }
}

Results against this controller:

Request Result Why

GET /books three times in a second

4th is 429

short allows 3 per second

POST /books/search three times

3rd is 429

@Throttle lowered short to 2 per 10 seconds

GET /books/health ten times

always 200

both throttlers are skipped

GET /reviews/latest

always 200

the class skips both throttlers

GET /reviews six times

6th is 429

long was switched back on, 5 per minute

A bare @SkipThrottle() means { default: true }: it skips only the throttler named default. With named throttlers such as short and long it skips nothing, which is a classic pitfall (a health check that still returns 429 under load). Spell the names out, as the example does.

Beyond the decorators, three module options decide per request or per client whether a limit applies, and blockDuration makes a login endpoint harder to brute-force than a plain window:

Blocking, skipping by user agent and skipping by predicate
import { Body, Controller, Module, Post } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { Throttle, ThrottlerGuard, ThrottlerModule, minutes, seconds } from '@nestjs/throttler';

@Controller('auth')
class AuthController {
  // 2 attempts per minute; after that the caller is blocked for 15 minutes.
  @Throttle({ default: { limit: 2, ttl: seconds(60), blockDuration: minutes(15) } })
  @Post('login')
  login(@Body() body: { email: string }) {
    return { ok: true, email: body.email };
  }
}

@Module({
  imports: [
    ThrottlerModule.forRoot({
      throttlers: [{ ttl: seconds(60), limit: 100 }],
      // never throttle the load balancer's probe
      ignoreUserAgents: [/^kube-probe\//],
      // or skip based on anything in the request
      skipIf: (context) => context.switchToHttp().getRequest().headers['x-internal'] === '1',
    }),
  ],
  controllers: [AuthController],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class LoginRateLimitModule {}

The third POST /auth/login within the minute is rejected with Retry-After: 900 (15 minutes); the request carrying X-Internal: 1 or the kube-probe user agent is not counted. Treat skipIf as a trust decision: a header that a client can set (as X-Internal above, used only to keep the example small) is not a safe condition unless the edge proxy strips it from external traffic.

Choosing the tracker

The tracker is the identity whose requests are counted. The default is req.ip, normalized so that IPv6 addresses are grouped by subnet (the ipv6SubnetPrefix option, default 64, stops a client from dodging the limit by rotating addresses inside its own /64). To count something else, such as an authenticated user, override getTracker() in a guard subclass, or pass a getTracker function in the options or in @Throttle():

Counting authenticated users by id and anonymous clients by IP
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class ThrottlerByUserGuard extends ThrottlerGuard {
  // Track authenticated users by id and everyone else by IP address.
  // With "trust proxy" configured, req.ip is already the real client address.
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.user?.id ? `user:${req.user.id}` : req.ip;
  }
}

The storage key is a SHA-256 hash of the class name, the handler name, the throttler name and the tracker, so limits are per route by default. Override generateKey to share one bucket across routes (for example one budget for all authentication endpoints).

Proxies and trust proxy

Behind a load balancer or reverse proxy, the socket peer is the proxy, so without further configuration every client shares one tracker and one bucket. The framework has to be told which proxies to trust so that req.ip reflects the real client. The configuration is HTTP-adapter specific:

Trusting a proxy on Express
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.set('trust proxy', 1); // trust exactly one hop: the load balancer in front
  await app.listen(3000);
}
await bootstrap();
Trusting a proxy on Fastify
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({ trustProxy: true }), // or a hop count, a CIDR list, an address
  );
  await app.listen(3000, '0.0.0.0');
}
await bootstrap();

Results of three requests whose X-Forwarded-For were 203.0.113.7, .8 and .9 and a limit of 2 per minute, using the guard above (tracker req.ip):

Setup Result

Express, no trust proxy

req.ip is 127.0.0.1 for all three, so the third request is 429

Express, trust proxy set to loopback

req.ip is 203.0.113.7, .8, .9 and all three succeed

Fastify, no trustProxy

req.ip is 127.0.0.1 and req.ips is undefined

Fastify, trustProxy: true

req.ip is the forwarded client, each client has its own bucket

Pitfalls that the verification surfaced:

  • Do not blindly use req.ips[0]. The official snippet overrides getTracker() with req.ips.length ? req.ips[0] : req.ip and says it works for both adapters. On Express ips lists the client first, but on Fastify with trustProxy: true the array was ["127.0.0.1", "203.0.113.7"] (nearest hop first), so ips[0] is the proxy and every client collapsed into one bucket. With trust proxy configured correctly, the default req.ip is already right on both adapters; only override the tracker to count something other than the address. On Fastify without trustProxy, req.ips is undefined, so req.ips.length throws a TypeError and every request becomes a 500.

  • Trusting too much is as bad as trusting too little. trust proxy: true or trustProxy: true believes the left-most X-Forwarded-For value, which a client can forge, so an attacker rotates the header to get an unlimited number of buckets. Trust a hop count or the proxies' addresses, and make sure the edge proxy overwrites the header.

  • A proxy that is not configured also changes cookie and Secure behaviour; see Cookies & Sessions and Fastify & HTTP Platforms for the platform side.

Storage and Redis

The built-in storage is an in-memory map in the process. That is correct for a single instance and wrong for a horizontally scaled deployment: each replica counts separately, so the effective limit is limit * replicas, and a restart resets every counter. For multiple instances, pass a shared storage to the options object. The contract is the single-method ThrottlerStorage interface exported by @nestjs/throttler:

export interface ThrottlerStorage {
  increment(
    key: string,
    ttl: number,
    limit: number,
    blockDuration: number,
    throttlerName: string,
  ): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }>;
}

The documentation points to the community package @nest-lab/throttler-storage-redis (maintained in the nest-lab repository, not by the Nest core team):

npm i @nest-lab/throttler-storage-redis ioredis
Redis-backed storage, URL from configuration
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { ThrottlerModule, seconds } from '@nestjs/throttler';

@Module({
  imports: [
    ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
    ThrottlerModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        throttlers: [{ ttl: seconds(60), limit: 100 }],
        // e.g. REDIS_URL=redis://redis:6379
        storage: new ThrottlerStorageRedisService(config.getOrThrow<string>('REDIS_URL')),
      }),
    }),
  ],
})
export class RedisThrottlingModule {}

On 2026-10-02 @nest-lab/throttler-storage-redis 1.2.0 declared peer ranges only up to Nest 11 (@nestjs/common and @nestjs/core ^11), and @nestjs/throttler 6.7.1 itself accepts Nest 12; npm’s resolver therefore complains on a Nest 12 project (installing it there needs --legacy-peer-deps). The module above compiles and builds against Nest 12. A hand-written storage is the shortest way to see the ThrottlerStorage contract in action:

A minimal ThrottlerStorage on any ioredis-compatible client
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import type { Redis } from 'ioredis';

type StorageRecord = Awaited<ReturnType<ThrottlerStorage['increment']>>;

/** Minimal fixed-window storage on any ioredis-compatible client. */
@Injectable()
export class SimpleRedisThrottlerStorage implements ThrottlerStorage, OnModuleDestroy {
  constructor(private readonly redis: Redis) {}

  async increment(key: string, ttl: number, limit: number, blockDuration: number, throttlerName: string): Promise<StorageRecord> {
    const hitKey = `throttle:${throttlerName}:${key}:hits`;
    const blockKey = `throttle:${throttlerName}:${key}:blocked`;

    const blockedFor = await this.redis.pttl(blockKey);
    if (blockedFor > 0) {
      return { totalHits: limit + 1, timeToExpire: 0, isBlocked: true, timeToBlockExpire: Math.ceil(blockedFor / 1000) };
    }

    const totalHits = await this.redis.incr(hitKey);
    if (totalHits === 1) await this.redis.pexpire(hitKey, ttl);
    const pttl = await this.redis.pttl(hitKey);

    if (totalHits > limit) {
      await this.redis.set(blockKey, '1', 'PX', blockDuration);
      return { totalHits, timeToExpire: Math.ceil(pttl / 1000), isBlocked: true, timeToBlockExpire: Math.ceil(blockDuration / 1000) };
    }
    return { totalHits, timeToExpire: Math.ceil(pttl / 1000), isBlocked: false, timeToBlockExpire: 0 };
  }

  onModuleDestroy() {
    this.redis.disconnect();
  }
}

Two instances of the application built on this storage and pointed at the same Redis share one counter: with a limit of 2, the first request on instance A and the second on instance B succeed, and the third on A and the next on B both receive 429 with Retry-After: 60. This sketch is a teaching aid, not a replacement for the community package: its INCR, PEXPIRE and SET steps are separate commands, so under concurrency two instances can race between them, whereas the community package performs the whole update in one Lua script. Use a real Redis in production, set a key prefix or a dedicated database for the throttler, and decide what a Redis outage means: an error thrown by the storage propagates out of the guard, so wrap the storage if you prefer to fail open.

WebSockets

ThrottlerGuard reads the request and response from the HTTP context, so it does not work unmodified for a WebSocket gateway. Extend it and override handleRequest() to take the tracker from the socket. The guard has to be bound only to the gateway (@UseGuards(WsThrottlerGuard)): do not register it with APP_GUARD, because global guards also run for HTTP routes, where switchToWs().getClient() is meaningless.

A throttler guard for socket.io gateways
import { WsException } from '@nestjs/websockets';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerLimitDetail, ThrottlerRequest } from '@nestjs/throttler';

@Injectable()
export class WsThrottlerGuard extends ThrottlerGuard {
  protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
    const { context, limit, ttl, throttler, blockDuration, generateKey } = requestProps;

    // socket.io: the peer address lives on the handshake.
    // With @nestjs/platform-ws use client._socket.remoteAddress instead.
    const name = throttler.name ?? 'default';
    const client = context.switchToWs().getClient();
    const tracker: string = client.handshake.address;
    const key = generateKey(context, tracker, name);
    const { totalHits, timeToExpire, isBlocked, timeToBlockExpire } =
      await this.storageService.increment(key, ttl, limit, blockDuration, name);

    if (isBlocked) {
      await this.throwThrottlingException(context, {
        limit, ttl, key, tracker, totalHits, timeToExpire, isBlocked, timeToBlockExpire,
      });
    }
    return true;
  }

  // Report the limit to the client as a WsException instead of an "Internal server error".
  protected async throwThrottlingException(_context: ExecutionContext, _detail: ThrottlerLimitDetail): Promise<void> {
    throw new WsException('Too Many Requests');
  }
}
Binding it to a gateway
import { UseGuards } from '@nestjs/common';
import { MessageBody, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { WsThrottlerGuard } from './ws-throttler.guard.js';

@UseGuards(WsThrottlerGuard)
@WebSocketGateway()
export class ChatGateway {
  @SubscribeMessage('chat.send')
  send(@MessageBody() text: string) {
    return { received: text }; // a plain return value becomes the acknowledgement
  }
}

With a limit of 2 per minute, a socket.io client receives acknowledgements {"received":"hi"} for the first two messages and then the event:

{ "status": "error", "message": "Too Many Requests", "cause": { "pattern": "chat.send", "data": "hi" } }

Details that differ from a copy of the official snippet:

  • The client address is read from client._socket.remoteAddress with @nestjs/platform-ws, and from client.handshake.address with @nestjs/platform-socket.io (as in the tracker above); the documentation’s hint says client.conn for socket.io. Behind a proxy that address is the proxy’s, so decide deliberately whether to trust the X-Forwarded-For header of the handshake request.

  • With several named throttlers, handleRequest() runs once per throttler, so pass throttler.name to generateKey() and to increment() as the example does; otherwise they share a counter. The name is typed string | undefined, so under strict the example normalizes it to default.

  • throwThrottlingException() throws an HttpException, which the WebSocket exception layer reports as "Internal server error". Overriding it to throw a WsException gives the client a meaningful message. When the limit is reached Nest emits an exception event, so the client needs a listener for it.

  • A handler’s plain return value is the socket.io acknowledgement, whereas returning { event, data } emits an event instead of calling the acknowledgement callback.

Per-connection and per-message limits are different policies; limiting the handshake itself is an HTTP concern handled by the HTTP guard on the upgrade route or the proxy.

GraphQL

For GraphQL the HTTP request is nested in the GraphQL context, so extend the guard and override getRequestResponse():

A throttler guard for GraphQL resolvers
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class GqlThrottlerGuard extends ThrottlerGuard {
  getRequestResponse(context: ExecutionContext) {
    const gqlCtx = GqlExecutionContext.create(context);
    const ctx = gqlCtx.getContext();
    return { req: ctx.req, res: ctx.res };
  }
}
Module with the guard and a context that exposes the request and response
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { GraphQLModule, Query, Resolver } from '@nestjs/graphql';
import { ThrottlerModule } from '@nestjs/throttler';
import { GqlThrottlerGuard } from './gql-throttler.guard.js';

@Resolver()
class HelloResolver {
  @Query(() => String)
  hello() {
    return 'hello';
  }
}

@Module({
  imports: [
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 2 }]),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      // The guard reads req/res from the GraphQL context.
      context: ({ req, res }: { req: unknown; res: unknown }) => ({ req, res }),
    }),
  ],
  providers: [HelloResolver, { provide: APP_GUARD, useClass: GqlThrottlerGuard }],
})
export class GqlRateLimitModule {}

Run against { hello }, the third query within a minute produced HTTP 200 (GraphQL reports errors in the body) with:

{
  "errors": [
    {
      "message": "ThrottlerException: Too Many Requests",
      "path": ["hello"],
      "extensions": { "code": "INTERNAL_SERVER_ERROR" }
    }
  ],
  "data": null
}

Notes:

  • The guard counts resolver invocations, not HTTP requests, and the key includes the resolver name. A query that selects the same field under three aliases ({ x: a y: a z: a }) consumed three hits of one bucket, and with a limit of 2 the third alias was rejected (the whole data became null because the field is non-nullable), while three different root fields each used a separate bucket and all succeeded. A request-level limit belongs on an HTTP guard or the proxy; query complexity and depth limits are a separate concern, see Security Best Practices.

  • The context function must expose req and res. The Express integration calls context({ req, res }); with the Fastify integration (@as-integrations/fastify) the function receives the request and reply as two positional arguments, and the same guard works with context: async (request, reply) ⇒ ({ req: request, res: reply }) (Fastify 5). Without it, res is undefined and every query fails with a TypeError about header.

  • Because the status is 200, clients and gateways that watch for 429 will not see it; map the error in an exception filter or Apollo’s formatError if you need a 429 or an error code of your own.

Encryption with Node crypto

Encryption is reversible: with the key, ciphertext turns back into plaintext. Nest ships no encryption abstraction on top of Node’s built-in node:crypto module, deliberately. Use it for values the application must read again, such as a third-party access token stored in the database or a personal data column; never for passwords (see Hashing and password storage).

aes-256-gcm, not aes-256-ctr

The official documentation shows aes-256-ctr. CTR provides confidentiality only: nothing detects a modified ciphertext, and an attacker who knows or guesses the plaintext layout can change it bit by bit without the key. The book this section was cross-checked against repeats the same CTR pattern; use an authenticated mode instead. aes-256-gcm encrypts and produces an authentication tag, and decryption fails if a single bit of the ciphertext, the nonce or the tag changed.

An injectable AES-256-GCM service with a key from configuration
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit nonce, the size GCM is designed for
const TAG_LENGTH = 16;

@Injectable()
export class EncryptionService {
  private readonly key: Buffer;

  constructor(config: ConfigService) {
    // ENCRYPTION_KEY: 64 hex characters, generated with `openssl rand -hex 32`
    this.key = Buffer.from(config.getOrThrow<string>('ENCRYPTION_KEY'), 'hex');
    if (this.key.length !== 32) {
      throw new Error('ENCRYPTION_KEY must be 32 bytes (64 hex characters)');
    }
  }

  /** Returns base64(iv || ciphertext || authTag). */
  encrypt(plaintext: string): string {
    const iv = randomBytes(IV_LENGTH); // a fresh nonce for every message
    const cipher = createCipheriv(ALGORITHM, this.key, iv, { authTagLength: TAG_LENGTH });
    const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
    return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString('base64');
  }

  decrypt(payload: string): string {
    const data = Buffer.from(payload, 'base64');
    if (data.length < IV_LENGTH + TAG_LENGTH) throw new Error('Invalid payload');
    const iv = data.subarray(0, IV_LENGTH);
    const tag = data.subarray(data.length - TAG_LENGTH);
    const ciphertext = data.subarray(IV_LENGTH, data.length - TAG_LENGTH);
    const decipher = createDecipheriv(ALGORITHM, this.key, iv, { authTagLength: TAG_LENGTH });
    decipher.setAuthTag(tag);
    // final() throws "Unsupported state or unable to authenticate data" if anything was altered
    return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
  }
}

The key is a 32-byte random value, never a password or a literal in the source. Generate it once, keep it in your secret store, and expose it to the application as an environment variable that ConfigService reads:

openssl rand -hex 32   # prints 64 hex characters: store as ENCRYPTION_KEY in the secret store

Design points that the example follows and that are easy to get wrong:

  • A fresh random nonce for every message, stored next to the ciphertext. Reusing a nonce with the same key under GCM destroys both confidentiality and authenticity. A 12-byte (96-bit) nonce is the size GCM is designed around; with random nonces keep the number of messages per key well below 2^32 and rotate the key before that.

  • Store nonce || ciphertext || tag as one value, as the service does (base64 of the three parts: 52 characters for a 9-character plaintext with this layout). Nothing else has to be stored.

  • Verify the tag length on decryption by passing authTagLength, so that an attacker cannot truncate the tag to make forgery cheaper.

  • A single bit flipped in the stored value makes decrypt() throw Unsupported state or unable to authenticate data from decipher.final(), and no plaintext is returned. Treat that exception as tampering or a wrong key, not as a retryable error. For comparison, the same experiment with aes-256-ctr turned pay 100 EUR into pay 900 EUR after flipping bits of the ciphertext, and decryption succeeded without any error.

  • Authenticated additional data (setAAD()) binds a ciphertext to its context, such as the row id, so that a valid value cannot be copied to another row. Add it when ciphertexts could be swapped between records.

  • Key rotation needs a key identifier stored with the value (a version byte in front of the nonce is enough), because decrypting needs the key the value was written with. For larger needs, prefer envelope encryption with a managed key service over homegrown rotation.

Use the service from any provider by importing EncryptionModule:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { Argon2Hasher, BcryptHasher } from './bcrypt-argon2.service.js';
import { EncryptionService } from './encryption.service.js';
import { ScryptPasswordHasher } from './password-hasher.service.js';

@Module({
  imports: [ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true })],
  providers: [EncryptionService, ScryptPasswordHasher, BcryptHasher, Argon2Hasher],
  exports: [EncryptionService, ScryptPasswordHasher, BcryptHasher, Argon2Hasher],
})
export class EncryptionModule {}

The constructor fails fast: starting the application with a missing ENCRYPTION_KEY throws from getOrThrow(), and a key of the wrong length throws ENCRYPTION_KEY must be 32 bytes (64 hex characters). That is preferable to discovering a misconfiguration when the first row is written.

Hashing and password storage

Hashing is one-way: you cannot recover the input, only check a candidate against the stored hash. Passwords must be hashed with a deliberately slow, salted, memory-hard function, not with a fast digest such as SHA-256 and not with encryption. Three choices are in practical use, all following the OWASP Password Storage Cheat Sheet:

Function Strengths Caveats

argon2id (argon2 package)

The OWASP first choice, memory-hard, native addon with prebuilt binaries; the encoded hash contains algorithm, parameters and salt

Native module; tune memory and time cost to your hardware

scrypt (node:crypto)

Built into Node.js, no dependency, memory-hard; recommended when native modules are not an option

You assemble the storage format yourself; memory use per hash is large (128 MiB at the parameters below)

bcrypt (bcrypt package)

Ubiquitous and well understood; the encoded hash contains the cost and salt

Native module; only the first 72 bytes of the password are used; not memory-hard

scrypt from node:crypto

scrypt runs on the libuv thread pool when the asynchronous form is used, so it does not block the event loop. The service below stores scrypt$N$r$p$salt$hash so the parameters travel with the hash and can be raised later, normalizes the password to Unicode NFKC so the same password typed on different keyboards verifies, and compares in constant time:

Password hashing with scrypt
import { Injectable } from '@nestjs/common';
import { randomBytes, scrypt, timingSafeEqual, ScryptOptions } from 'node:crypto';

// OWASP-recommended scrypt parameters: N=2^17, r=8, p=1 (about 128 MiB per hash)
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 } satisfies ScryptOptions;
const KEY_LENGTH = 64;

const scryptAsync = (password: string, salt: Buffer, keylen: number, options: ScryptOptions) =>
  new Promise<Buffer>((resolve, reject) =>
    scrypt(password.normalize('NFKC'), salt, keylen, options, (err, key) => (err ? reject(err) : resolve(key))),
  );

@Injectable()
export class ScryptPasswordHasher {
  /** Returns `scrypt$N$r$p$salt$hash` so the parameters travel with the hash. */
  async hash(password: string): Promise<string> {
    const salt = randomBytes(16);
    const derived = await scryptAsync(password, salt, KEY_LENGTH, PARAMS);
    return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64'), derived.toString('base64')].join('$');
  }

  async verify(password: string, stored: string): Promise<boolean> {
    const [scheme, n, r, p, salt, hash] = stored.split('$');
    if (scheme !== 'scrypt' || !salt || !hash) return false;
    const expected = Buffer.from(hash, 'base64');
    const derived = await scryptAsync(password, Buffer.from(salt, 'base64'), expected.length, {
      N: Number(n), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem,
    });
    return timingSafeEqual(derived, expected); // constant-time comparison
  }
}

The parameters N = 2^17, r = 8, p = 1 use about 128 MiB per hash (the default maxmem of 32 MiB would reject them, hence the explicit maxmem). That is what OWASP lists for scrypt, and it also means ten simultaneous logins need about 1.3 GiB: cap concurrency or put a rate limit in front of the login route (see Rate limiting with @nestjs/throttler) before raising the cost. One hash or verification takes roughly 0.15 seconds on a typical development machine.

The official documentation now recommends PasswordHasher from @nestjs/authentication (a pre-1.0 0.0.x package) for the same job: it uses scrypt at these parameters, produces a self-describing string, answers a dummy hash for unknown users so response times do not reveal which accounts exist, and offers needsRehash(). If you adopt that package, use it instead of the sketch above; the sketch shows what such a helper does underneath. Either way, hashing an unknown user’s password against a dummy hash is worth copying, because an early return makes login timing reveal which emails are registered.

bcrypt and argon2

The documentation states that argon2 and bcrypt work with Nest unchanged: call their functions from your own provider.

npm i argon2 bcrypt
npm i -D @types/bcrypt
bcrypt and argon2 providers
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
import * as bcrypt from 'bcrypt';

@Injectable()
export class BcryptHasher {
  hash(password: string): Promise<string> {
    return bcrypt.hash(password, 12); // cost factor 12; only the first 72 bytes are used
  }
  verify(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}

@Injectable()
export class Argon2Hasher {
  hash(password: string): Promise<string> {
    // argon2id is the default type; options are tuned per the OWASP cheat sheet
    return argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 });
  }
  verify(password: string, hash: string): Promise<boolean> {
    return argon2.verify(hash, password);
  }
  needsRehash(hash: string): boolean {
    return argon2.needsRehash(hash, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
  }
}

Output: bcrypt produces $2b$12$…​ strings and argon2 $argon2id$v=19$m=19456,p=1,t=2$…​; both verify the right password and reject a wrong one. The bcrypt 72-byte limit is real: two passwords that share their first 72 characters and differ afterwards both verify against the same hash. Pre-hash with a digest or reject long passwords if that matters to you. argon2.needsRehash() is what lets you raise the cost over time: after a successful verify(), check it and store a new hash while the plaintext is at hand.

Both packages are native addons: install them in the image that runs the application, on the same platform they were built for, and keep native modules out of a bundle (mark them as externals).

Pitfalls

  • Never log or return a hash or a plaintext password; use the techniques in Serialization to keep password fields out of responses.

  • Compare in constant time. argon2.verify() and bcrypt.compare() do; a hand-rolled scrypt check must use timingSafeEqual(), as above, never ===.

  • Do not invent schemes: no fixed salt (the 'salt' literal in the official scrypt encryption snippet exists only to derive a key from a password), no home-made peppering, no single round of SHA.

  • Hashing is not for secrets you need back, and encryption is not for passwords. API tokens that are only ever checked can be stored as a SHA-256 of a high-entropy random value, since such tokens do not need a slow hash.

  • Throttle the login route. A slow hash protects stolen databases; a rate limit protects the endpoint, and each needs the other (see Rate limiting with @nestjs/throttler).

Where the book is outdated

The book teaches encryption with aes-256-ctr, the example that the official documentation also still shows. That mode has no integrity protection, as the bit-flip experiment above demonstrates, so this page uses aes-256-gcm with a random nonce and a key read from configuration. For password storage, the official documentation has moved to scrypt (and the PasswordHasher of @nestjs/authentication) with argon2 and bcrypt as accepted alternatives, so a bcrypt-only recipe should be seen as one choice among three. For throttling, remember that since @nestjs/throttler 5 ttl is in milliseconds, @Throttle() and @SkipThrottle() take objects keyed by throttler name, and the module takes an array or an options object; examples written against the v4 shape (ttl: 60, limit: 10 in seconds, @Throttle(10, 60)) behave very differently or do not compile.

What changed recently

  • @nestjs/throttler 6.x is current (6.7.1 on 2026-10-02) and its peer range includes Nest 12. Since version 5 the time unit is milliseconds and named throttlers are keyed by name. The current options include ipv6SubnetPrefix (default /64 grouping of IPv6 clients), errorMessage as a string or a function, setHeaders, skipIf and ignoreUserAgents.

  • The 2026 documentation restructure moved rate limiting and encryption under the Security chapter (/security/rate-limiting, /security/encryption-and-hashing) and made PasswordHasher from the pre-1.0 @nestjs/authentication package the primary hashing recommendation, with argon2 and bcrypt kept as alternatives.

  • Nest 12.1 added built-in security headers and CSRF protection next to rate limiting in the same chapter; they are complementary to the throttler and covered in Security Best Practices.