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:
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 {}
Source: NestJS documentation: Rate limiting, "Multiple Throttler Definitions" and Async Configuration
Things to know about this shape:
-
ttlis in milliseconds (since@nestjs/throttler5). The package exports the helpersseconds(),minutes(),hours(),days()andweeks(), which return milliseconds and make the values readable, sottl: seconds(60)instead ofttl: 60000. -
forRoot()accepts either an array of throttler definitions (the form used in the docs' multiple-definitions example) or an options object whosethrottlersproperty holds that array. Use the object form as soon as you needstorage,errorMessage,skipIfand other global options. -
A throttler without a
nameis calleddefault. 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
ConfigServicearrive as strings when they come from the process environment, so validate and convert them (see Configuration); theget<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 unnameddefaultthrottler produces the plainX-RateLimit-Limitheader. Clients that look forRetry-Afterwill not find it when the throttlers are named: the header isRetry-After-shortorRetry-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; theerrorMessageoption (a string, or a function of theExecutionContextand the limit details) replaces it, as shown. -
A blocked request is counted: the
limit + 1-th request is the first one rejected, andblockDuration(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.
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 |
|---|---|---|
|
4th is |
|
|
3rd is |
|
|
always |
both throttlers are skipped |
|
always |
the class skips both throttlers |
|
6th is |
|
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:
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():
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:
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();
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();
Source: Fastify: trustProxy
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 |
|
Express, |
|
Fastify, no |
|
Fastify, |
|
Pitfalls that the verification surfaced:
-
Do not blindly use
req.ips[0]. The official snippet overridesgetTracker()withreq.ips.length ? req.ips[0] : req.ipand says it works for both adapters. On Expressipslists the client first, but on Fastify withtrustProxy: truethe array was["127.0.0.1", "203.0.113.7"](nearest hop first), soips[0]is the proxy and every client collapsed into one bucket. Withtrust proxyconfigured correctly, the defaultreq.ipis already right on both adapters; only override the tracker to count something other than the address. On Fastify withouttrustProxy,req.ipsisundefined, soreq.ips.lengththrows aTypeErrorand every request becomes a500. -
Trusting too much is as bad as trusting too little.
trust proxy: trueortrustProxy: truebelieves the left-mostX-Forwarded-Forvalue, 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
Securebehaviour; 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
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:
ThrottlerStorage on any ioredis-compatible clientimport { 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.
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');
}
}
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.remoteAddresswith@nestjs/platform-ws, and fromclient.handshake.addresswith@nestjs/platform-socket.io(as in the tracker above); the documentation’s hint saysclient.connfor socket.io. Behind a proxy that address is the proxy’s, so decide deliberately whether to trust theX-Forwarded-Forheader of the handshake request. -
With several named throttlers,
handleRequest()runs once per throttler, so passthrottler.nametogenerateKey()and toincrement()as the example does; otherwise they share a counter. The name is typedstring | undefined, so understrictthe example normalizes it todefault. -
throwThrottlingException()throws anHttpException, which the WebSocket exception layer reports as"Internal server error". Overriding it to throw aWsExceptiongives the client a meaningful message. When the limit is reached Nest emits anexceptionevent, 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():
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 };
}
}
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 wholedatabecamenullbecause 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
reqandres. The Express integration callscontext({ 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 withcontext: async (request, reply) ⇒ ({ req: request, res: reply })(Fastify 5). Without it,resisundefinedand every query fails with aTypeErroraboutheader. -
Because the status is
200, clients and gateways that watch for429will not see it; map the error in an exception filter or Apollo’sformatErrorif you need a429or 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.
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');
}
}
Source: NestJS documentation: Encryption and Hashing, "Encryption" and Node.js: crypto.createCipheriv()
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 || tagas 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()throwUnsupported state or unable to authenticate datafromdecipher.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 withaes-256-ctrturnedpay 100 EURintopay 900 EURafter 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 ( |
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 ( |
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 ( |
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:
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
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()andbcrypt.compare()do; a hand-rolled scrypt check must usetimingSafeEqual(), as above, never===. -
Do not invent schemes: no fixed salt (the
'salt'literal in the officialscryptencryption 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/throttler6.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 includeipv6SubnetPrefix(default/64grouping of IPv6 clients),errorMessageas a string or a function,setHeaders,skipIfandignoreUserAgents. -
The 2026 documentation restructure moved rate limiting and encryption under the Security chapter (
/security/rate-limiting,/security/encryption-and-hashing) and madePasswordHasherfrom the pre-1.0@nestjs/authenticationpackage the primary hashing recommendation, withargon2andbcryptkept 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.