Cookies & Sessions
|
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), Node.js 20.19+ or 22.12+, TypeScript 6, Express 5 and
Fastify 5, and was checked on 2026-10-02. The cookie examples use the API that NestJS ships since 12.1; the session examples
use express-session 1.19 and @fastify/secure-session 8.4, with cookie-parser 1.4 and @fastify/cookie 11.1 for the
interoperability section. The snippets use the NestJS 12.1.2 CLI project layout (ESM, Vitest and supertest) and behave the same on
the Express and the Fastify adapter; the headers and bodies shown are what the application sends.
HTTP is stateless, so a browser and a server need a convention to recognise each other across requests. That convention is the
cookie: a small named value the server sends in a Set-Cookie response header and the browser returns in a Cookie request
header. A session is a layer on top: the cookie carries only an identifier (or an encrypted blob) and the state lives
behind it. This page covers both in NestJS. Cookies became a first-class, adapter-agnostic part of the framework in 12.1, so
for the first time the same decorators and adapter methods work on Express, Fastify and any custom adapter. Sessions are still
provided by platform packages. For the authentication that usually sits on top of them, see
Authentication; for the browser-side attacks cookies invite, see
Security Headers, CORS & CSRF.
What NestJS gives you, and what it does not
| Need | Built into Nest (since 12.1) | Platform package |
|---|---|---|
Read request cookies |
|
|
Read signed cookies |
|
|
Write and clear cookies |
|
|
Server-side sessions |
|
|
Nest never assigns req.cookies itself. The built-in decorators parse the Cookie header on demand, at most once per request,
and only when a handler parameter asks for cookies. Applications that already use cookie-parser or @fastify/cookie keep
working unchanged; the two approaches cooperate as described in the interoperability section. Sessions have no
built-in implementation: @Session() just extracts request.session, which a platform package must have populated.
Where this runs in the request pipeline: parsing happens when the handler’s parameters are resolved, just before the handler
body and after guards have run, so a guard that needs a cookie reads it from the request object or
through its own parsing, while a handler can use the decorators. Session middleware (app.use(…)) runs earlier, as global
middleware, before any guard. The ordering is covered in Request Lifecycle and
Middleware.
Reading cookies with @Cookies()
@Cookies() is a parameter decorator exported from @nestjs/common. Without an argument it returns every cookie as an object
keyed by cookie name; with a name it returns that one value, or undefined when the cookie was not sent.
import {
Body,
Controller,
Cookies,
Delete,
Get,
ParseIntPipe,
Put,
Res,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Controller('preferences')
export class PreferencesController {
constructor(private readonly adapterHost: HttpAdapterHost) {}
@Get()
findAll(@Cookies() cookies: Record<string, string>) {
return cookies;
}
@Get('theme')
findTheme(@Cookies('theme') theme?: string) {
return theme ?? 'light';
}
@Get('cart')
findCart(@Cookies('cartSize', ParseIntPipe) cartSize: number) {
return { cartSize };
}
// setTheme() and resetTheme() are shown in the next section
}
Requests with curl, and what comes back:
curl http://localhost:3000/preferences -H 'Cookie: theme=dark; a=b%20c; a=second'
{ "theme": "dark", "a": "b c" }
The behaviour worth knowing, the same on both adapters:
-
Values are percent-decoded (
b%20cbecameb c). -
When the same name appears twice, the first occurrence wins, because browsers send the cookie with the most specific path first (the second
awas ignored). -
A named cookie that was not sent is
undefined, sofindThemefalls back tolight. -
@Cookies()does not verify anything: values come from the client and can be forged. Read anything you must trust with@SignedCookies(), covered below.
Pipes after the cookie name
As with @Query() or @Param(), pipes can follow the name. ParseIntPipe turns cartSize=3 into the number 3; a missing
or non-numeric value is rejected with a 400 (the response for cartSize=abc):
{ "message": "Validation failed (numeric string is expected)", "error": "Bad Request", "statusCode": 400 }
A missing cookie also returns 400 unless the pipe is built with its optional option. Pipes receive cookie parameters with
ArgumentMetadata.type set to 'custom', so a global ValidationPipe skips them unless you enable its
validateCustomDecorators option; see Pipes & Validation and
Custom Decorators. If your application defines its own @Cookies() built with
createParamDecorator(), you can delete it and use the built-in one.
Writing and clearing cookies
Writing goes through the HTTP adapter, not through a decorator. The adapter has two methods:
-
setCookie(response, name, value, options?)appends aSet-Cookieheader -
clearCookie(response, name, options?)appends aSet-Cookieheader that expires the cookie immediately
Both take the native response object (an Express Response, a FastifyReply, …) as first argument. Inject
HttpAdapterHost, obtain the adapter from its httpAdapter property, and get the response with @Res({ passthrough: true }).
The passthrough option keeps Nest in charge of sending the response, so interceptors, exception filters and serialization
still apply; the controller never needs to know the platform. Each call appends its own header, so several cookies set in one
request are all sent.
@Put('theme')
setTheme(
@Body('theme') theme: string,
@Res({ passthrough: true }) res: unknown,
) {
this.adapterHost.httpAdapter.setCookie(res, 'theme', theme, {
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // one year, in seconds
});
}
@Delete('theme')
resetTheme(@Res({ passthrough: true }) res: unknown) {
this.adapterHost.httpAdapter.clearCookie(res, 'theme');
}
curl -i -X PUT http://localhost:3000/preferences/theme \
-H 'Content-Type: application/json' -d '{"theme":"dark"}'
curl -i -X DELETE http://localhost:3000/preferences/theme
The two Set-Cookie headers sent (identical on Express and Fastify):
Set-Cookie: theme=dark; Path=/; Max-Age=31536000; SameSite=Lax
Set-Cookie: theme=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Because the methods only need the response object, you can also call them from a guard or an interceptor, using
context.switchToHttp().getResponse() (see Guards and
Interceptors). They are optional members of the HttpServer interface and are
implemented once in AbstractHttpAdapter on top of appendHeader(), so every adapter that extends it, including
ExpressAdapter and FastifyAdapter, supports them. For the adapter abstraction itself, see
Platform Agnosticism & Standalone Applications.
Options (CookieSerializeOptions)
The options argument is a CookieSerializeOptions, exported from @nestjs/common:
| Option | Meaning | Default |
|---|---|---|
|
|
|
|
|
not set |
|
|
not set |
|
|
not set |
|
|
|
|
|
|
|
|
not set |
|
|
|
|
Non-standard |
not set |
|
Signs the value with the first configured secret. |
|
Two rules follow from the table. First, apart from path nothing has a default, httpOnly included, which matches Express
and @fastify/cookie; for any cookie that carries an identity, set httpOnly, secure and sameSite explicitly. Second,
maxAge is in seconds, as in RFC 6265 and @fastify/cookie, while Express' res.cookie() takes milliseconds. A value
copied unchanged from res.cookie() makes the cookie live 1000 times longer, so when you migrate, convert it (one day is
60 * 60 * 24).
The value must be a string and is always percent-encoded with encodeURIComponent() (and decoded again by @Cookies()), so
any string is safe to store; convert other types yourself, for example with JSON.stringify(). clearCookie() writes an empty
cookie with Max-Age=0 and an Expires date in the past, and ignores maxAge, expires and signed. Browsers only remove a
cookie when path and domain match those it was set with, so pass the same values to both methods. Since path defaults to
/ in both, clearCookie(res, 'theme') clears a cookie set with setCookie(res, 'theme', value).
Security attributes in practice
The attributes are the whole security model of a cookie, so it is worth seeing what each combination produces. The following handler sets three cookies; the resulting headers follow.
@Get('attributes')
attributes(@Res({ passthrough: true }) res: unknown) {
const http = this.adapterHost.httpAdapter;
http.setCookie(res, '__Host-sid', 'abc', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
});
http.setCookie(res, 'embed', '1', {
secure: true,
sameSite: 'none',
partitioned: true,
});
http.setCookie(res, 'prefs', JSON.stringify({ lang: 'es', tz: 'UTC' }), {
sameSite: 'lax',
priority: 'low',
domain: 'example.com',
path: '/shop',
});
}
Set-Cookie: __Host-sid=abc; Path=/; HttpOnly; Secure; SameSite=Strict
Set-Cookie: embed=1; Path=/; Secure; Partitioned; SameSite=None
Set-Cookie: prefs=%7B%22lang%22%3A%22es%22%2C%22tz%22%3A%22UTC%22%7D; Path=/shop; Domain=example.com; Priority=Low; SameSite=Lax
| Attribute | When and why |
|---|---|
|
Always for session identifiers and tokens. A cross-site-scripting bug can then still make requests as the user but cannot exfiltrate the cookie. |
|
Always in production. It requires HTTPS end to end as seen by the browser; behind a TLS-terminating proxy the application must know that, see the Express session section. |
|
|
|
Opts a third-party cookie into per-top-level-site storage (CHIPS). Requires |
|
Leave it out for a host-only cookie. Setting it makes the cookie available to every subdomain, which widens the attack surface. |
|
Scopes the cookie to a URL prefix. It is a convenience, not a security boundary. |
|
Without either, the cookie lives until the browser session ends. Prefer short lifetimes for credentials. |
Nest does not enforce the rules of the Host- and Secure- name prefixes: if you use them (as Host-sid above), set
the attributes they require yourself, namely secure: true for both, plus path: '/' and no domain for Host-.
Rejected names and attributes
setCookie() and clearCookie() reject invalid input instead of escaping it, so a name or an attribute can never inject
extra attributes or headers through ;, CR or LF. They throw a TypeError when:
-
the name is not a valid RFC 7230 token (spaces,
;,=or control characters) -
pathcontains;or a character outside printable ASCII -
domainis not a valid host name (a leading dot is allowed) -
maxAgeis not an integer, orexpiresis not a validDate -
sameSiteorpriorityis not an allowed value -
sameSiteis'none', orpartitionedistrue, whilesecureis nottrue(browsers silently drop such cookies, so Nest reports the mistake) -
the value is not a string
The content of a value is never rejected: a ; or line break is percent-encoded. The output for the value
a;b\r\nc d is note=a%3Bb%0D%0Ac%20d; Path=/. These errors are not HTTP exceptions, so a handler that lets one escape
returns 500 Internal Server Error; the server log shows, for example:
TypeError: Invalid cookie name: "bad name"
TypeError: Invalid options for cookie "bad": "sameSite: 'none'" requires "secure: true"
If a cookie name or attribute comes from user input, validate it first (Pipes & Validation).
Signed cookies and secret rotation
A signed cookie carries an HMAC signature next to its value, so the server can detect tampering. Signing is enabled by giving
the application a secret through the cookies option of NestFactory.create(). Secrets must never be literals in source
code. The cookies option is read while the application is created, so it cannot come from a ConfigService that the
application itself provides; read it from the environment, validate it at startup and fail fast.
// cookie-secrets.ts: COOKIE_SECRETS=<current>,<previous>
export function cookieSecrets(env: NodeJS.ProcessEnv = process.env): string[] {
const secrets = (env.COOKIE_SECRETS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (secrets.length === 0) {
throw new Error('COOKIE_SECRETS is not set (generate one: openssl rand -hex 32)');
}
return secrets;
}
// bootstrap.ts (called from main.ts)
import { NestFactory } from '@nestjs/core';
import { CookiesModule } from './cookies.module.js';
import { cookieSecrets } from './cookie-secrets.js';
export async function bootstrap() {
const app = await NestFactory.create(CookiesModule, {
cookies: { secret: cookieSecrets() },
});
await app.listen(process.env.PORT ?? 3000);
return app;
}
The check matters: a secret that is undefined, for example because an environment variable is missing, counts as no
secret. The application starts, and the error surfaces only when a request signs or reads a cookie. With no
secret, on both adapters, setCookie() with signed: true and a @SignedCookies() parameter both throw
(Cannot sign cookie "uid": no cookie secret is configured), which is a 500 rather than every signed cookie silently reading
as undefined. An empty secret (an empty string, an empty array or an array with an empty entry) throws when the application is
created: The "cookies.secret" option must be a non-empty string or a non-empty array of non-empty strings. Generate real
secrets with openssl rand -hex 32 and keep them in the secret store of your platform; the
Configuration page shows schema validation for the rest of the settings.
Setting and reading a signed cookie
@Controller('auth')
export class AuthController {
constructor(
private readonly adapterHost: HttpAdapterHost,
private readonly authService: AuthService,
) {}
@Post('login')
signIn(
@Body() signInDto: SignInDto,
@Res({ passthrough: true }) res: unknown,
) {
const user = this.authService.signIn(signInDto);
this.adapterHost.httpAdapter.setCookie(res, 'uid', String(user.id), {
signed: true,
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60, // one hour, in seconds
});
}
@Get('profile')
getProfile(@SignedCookies('uid') userId?: string) {
if (!userId) {
throw new UnauthorizedException();
}
return this.authService.findProfile(userId);
}
@Post('logout')
signOut(@Res({ passthrough: true }) res: unknown) {
this.adapterHost.httpAdapter.clearCookie(res, 'uid');
}
}
curl -i -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' -d '{"email":"ada@example.com"}'
Set-Cookie: uid=s%3A42.HE23zH%2Bgv9B4Fkj52gDbVgCprL4iVuytTFdW4C%2Bcx6E; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
@SignedCookies('uid') resolves to the original value (42) when the signature verifies. It resolves to undefined when the
cookie is missing, unsigned, tampered with, or signed with an unknown secret. The profile
request returns { "id": 42, "name": "Ada" } for the genuine cookie and 401 for uid=42, for a one-character edit of the
signed value, and for a value signed with an unconfigured secret. Called without a name, @SignedCookies() returns an object
with only the cookies whose signature verifies (a request carrying the genuine uid and a plain other=1 produces
{ "uid": "42" }). Like @Cookies(), it accepts pipes after the name.
Two properties of the format are worth stating. Signing protects integrity, not confidentiality: the value is plain text that
the client can read, so never put sensitive data in a signed cookie. And, as with the cookie-signature package, the cookie
name is not part of the signed data: a value signed for uid also verifies when the client sends it as other (the swapped request returns { "other": "42" }). Do not reuse one secret for cookies whose meaning depends on their name
unless the value itself encodes it.
Rotating secrets
To replace a secret without logging everyone out, pass an array. Nest signs new cookies with the first secret and verifies incoming cookies against all of them. Signatures are compared in constant time and against every secret, so response time does not reveal which one matched.
const app = await NestFactory.create(AppModule, {
cookies: {
// COOKIE_SECRETS="<new>,<previous>" parsed by cookieSecrets() above
secret: cookieSecrets(),
},
});
The procedure is: generate a new secret and put it at the front of the list (deploy); keep the old one for at least the longest
maxAge you issue; then remove it. In the test, a cookie signed with the previous secret by an independent HMAC computation
(s: + value + . + base64 HMAC-SHA256 without padding) was accepted ({ "id": 7, "name": "Ada" }), while one signed with a
secret outside the list returned 401.
Signature format and migration
Nest signs in the format of the cookie-signature package, which cookie-parser and express-session also use: the s:
prefix, the value, a dot, and the unpadded base64 HMAC-SHA256 of the value. As a result:
-
cookies signed with the same secret by Express (
res.cookie()withsigned),cookie-parserorexpress-sessionkeep verifying after you move to the built-in API, andcookie-parsercan verify cookies signed by Nest -
values signed by
@fastify/cookie, which omits thes:prefix, verify as well -
the opposite direction does not work:
@fastify/cookie’s `unsignCookie()cannot verify cookies signed by Nest, because of thes:prefix
Using cookie-parser or @fastify/cookie
Before 12.1, reading cookies required cookie-parser (Express) or @fastify/cookie (Fastify). Code that uses them keeps
working, and the built-in decorators defer to them:
-
@Cookies()returnsreq.cookiesas is whenever a middleware or plugin has populated it, and parses the header itself only otherwise. The package’s behaviour therefore carries over: with a secret,cookie-parsermoves signed cookies fromreq.cookiestoreq.signedCookies, and turnsj:-prefixed JSON cookies into objects. -
When the
cookies.secretapplication option is set,@SignedCookies()always verifies the rawCookieheader against the Nest secrets and ignoresreq.signedCookies. Configure the same secrets as your middleware so the cookies it signed keep verifying. -
Without
cookies.secret,@SignedCookies()falls back toreq.signedCookiespopulated bycookie-parserand treats the cookies it marked invalid as missing.@fastify/cookiedoes not populatereq.signedCookies, so on Fastify@SignedCookies()requires thecookies.secretoption.
Express: cookie-parser
npm i cookie-parser
npm i -D @types/cookie-parser
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
export function useCookieParser(app: NestExpressApplication, secret: string) {
app.use(cookieParser(secret));
}
The first argument is an optional secret (or an array) for signed cookies; the secret should again come from configuration, not
from a literal (the official page uses a placeholder string for brevity). With a plain=1 cookie and a cookie signed with that
secret, Express gives req.cookies = { "plain": "1" } and req.signedCookies = { "tok": "9" }, and both
decorators return the same split. Writing with the Express API still works and produces key=value; Path=/, but remember
its maxAge is in milliseconds:
@Get('express')
express(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
response.cookie('key', 'value');
return { cookies: request.cookies, signedCookies: request.signedCookies };
}
Fastify: @fastify/cookie
npm i @fastify/cookie
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import fastifyCookie from '@fastify/cookie';
export async function useFastifyCookie(
app: NestFastifyApplication,
secret: string,
) {
await app.register(fastifyCookie, { secret });
}
With the plugin registered and cookies.secret set, @Cookies() returns the plugin’s req.cookies, which still
contains the signed cookie in its raw s:9.<digest> form, while @SignedCookies() returns { "tok": "9" }. With the
plugin but without cookies.secret, the @SignedCookies() handler returns 500, because the plugin does not populate
req.signedCookies. Use request.cookies or reply.setCookie() directly only if you must; the adapter methods and decorators
are portable.
If you remove either package, replace code that reads request.cookies or calls reply.setCookie() / res.cookie() with the
decorators and adapter methods, converting maxAge to seconds.
Sessions
A session keeps per-user state on the server (or, for @fastify/secure-session, inside an encrypted cookie) across requests,
identified by a cookie. They suit server-rendered applications (see MVC, Static Files & Compression)
and first-party browser clients, where revoking a login by deleting server state is valuable. Choosing between sessions,
signed cookies and tokens is summarised below.
| Approach | Strengths | Costs |
|---|---|---|
Signed cookie (built in) |
No server state, no dependency, integrity protected. |
Value is readable by the client, size-limited, cannot be revoked before expiry without extra state. |
Server-side session ( |
Opaque identifier; state and revocation under your control; can hold large or sensitive state. |
Needs a shared store to scale beyond one process; the session lookup is on every request. |
Encrypted cookie session ( |
Stateless server, value is encrypted and authenticated. |
Size limited to the cookie, no server-side revocation, key management matters. |
Bearer token (JWT) |
Fits non-browser clients and APIs; see Authentication and JWT and JOSE. |
Revocation and storage in the browser are your problem. |
The lifecycle of a cookie-backed session on the first and later requests:
Express: express-session
npm i express-session
npm i -D @types/express-session
The middleware is applied globally, normally in main.ts. The session secret must also come from configuration. Because this
happens after the application exists, ConfigService is available here, unlike for the cookies option above.
import type { NestExpressApplication } from '@nestjs/platform-express';
import { ConfigService } from '@nestjs/config';
import session from 'express-session';
export function setupSession(app: NestExpressApplication) {
const config = app.get(ConfigService);
app.set('trust proxy', 1); // required for secure cookies behind a TLS proxy
app.use(
session({
// First secret signs; all of them verify (rotation).
secret: [
config.getOrThrow<string>('SESSION_SECRET'),
...(config.get<string>('SESSION_SECRET_PREVIOUS')?.split(',') ?? []),
],
resave: false,
saveUninitialized: false,
name: 'sid',
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 8, // express-session takes milliseconds
},
}),
);
}
What the options do:
-
secretsigns the session identifier cookie. A single string or an array; with an array only the first element signs, and all of them verify, which gives you the same rotation scheme as for signed cookies. Use a long random value (openssl rand -hex 32). -
resave: falsestops sessions that were not modified from being written back to the store. The default istrue, but relying on it is deprecated because the default will change. -
saveUninitialized: falsedoes not store a session that is new but unmodified. It suits login sessions, reduces storage, helps with laws that require consent before setting a cookie, and avoids races when a client sends several parallel requests without a session. -
cookie.secure: trueis recommended but needs HTTPS: over plain HTTP the cookie is not set at all. Behind a proxy that terminates TLS you must also set Express'trust proxy, as the snippet does. In the test, a request carryingX-Forwarded-Proto: httpsreceivedsid=s%3A<id>.<signature>; Path=/; Expires=…; HttpOnly; Secure; SameSite=Lax, while the same request over plain HTTP received noSet-Cookieheader. -
cookie.maxAgehere is in milliseconds, unlike Nest’s built-insetCookie().
The default server-side storage is purposely not designed for production: it leaks memory under most conditions, does not
scale past a single process and is meant for debugging and development. Production needs one of the compatible stores listed
in the express-session README, backed by a shared service such as Redis; a Redis store is also what makes sessions survive a
restart and work across several instances (see Caching for Redis in the Bookshelf).
Using the session from handlers, with the SessionData interface augmented so request.session.visits is typed:
@Session() decorator (session.controller.ts)import { Controller, Get, Post, Req, Session } from '@nestjs/common';
import type { Request } from 'express';
declare module 'express-session' {
interface SessionData {
visits?: number;
userId?: number;
}
}
@Controller('session')
export class SessionController {
@Get('visits')
visits(@Req() request: Request) {
request.session.visits = request.session.visits
? request.session.visits + 1
: 1;
return { visits: request.session.visits };
}
@Get('visits-decorator')
visitsDecorator(@Session() session: Record<string, any>) {
session.visits = session.visits ? session.visits + 1 : 1;
return { visits: session.visits };
}
}
Three calls with the returned cookie replayed gave { "visits": 1 }, { "visits": 2 } and, through the decorator,
{ "visits": 3 }. Note that a test client such as a supertest agent refuses to resend a Secure cookie over http://, so the
test replays the cookie by hand; a real browser on HTTPS does it automatically.
Login and logout: regenerate and destroy
Two methods of the express-session session object are essential for authentication. After a successful login call
regenerate() so the browser gets a new identifier (otherwise an identifier planted before login, a session fixation attack,
becomes an authenticated one), and on logout call destroy().
@Post('login')
login(@Req() request: Request) {
return new Promise<{ ok: true }>((resolve, reject) => {
request.session.regenerate((err) => {
if (err) return reject(err);
request.session.userId = 42; // after verifying credentials
request.session.save((saveErr) =>
saveErr ? reject(saveErr) : resolve({ ok: true }),
);
});
});
}
@Get('me')
me(@Req() request: Request) {
return { userId: request.session.userId ?? null };
}
@Post('logout')
logout(@Req() request: Request) {
return new Promise<{ ok: true }>((resolve, reject) => {
request.session.destroy((err) => (err ? reject(err) : resolve({ ok: true })));
});
}
Source: NestJS: Session, "Use with Express (default)"; the regenerate(), save() and destroy() methods are documented in the express-session README that the official page links.
The identifier cookie before login and the one issued by POST /session/login differ, GET /session/me then
returns { "userId": 42 }, and after POST /session/logout it returns { "userId": null }. The save() call is deliberate:
it makes the handler wait until the store has the data before the response (and the new cookie) is sent.
Fastify: @fastify/secure-session
npm i @fastify/secure-session
Unlike express-session, @fastify/secure-session keeps no server state: the session is serialised, encrypted and
authenticated with a key, and stored inside the cookie. You supply either a secret plus salt (the official page’s
example) or, as here, a pregenerated 32-byte key. Key rotation is supported by the plugin as well.
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { ConfigService } from '@nestjs/config';
import secureSession from '@fastify/secure-session';
export async function setupSession(app: NestFastifyApplication) {
const config = app.get(ConfigService);
await app.register(secureSession, {
// 32 random bytes, hex-encoded: `openssl rand -hex 32`
key: Buffer.from(config.getOrThrow<string>('SESSION_KEY'), 'hex'),
cookieName: 'session',
cookie: {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 8, // seconds
},
});
}
import { Controller, Get, Post, Req, Session } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import * as secureSession from '@fastify/secure-session';
declare module '@fastify/secure-session' {
interface SessionData {
visits: number;
}
}
@Controller('session')
export class FastifySessionController {
@Get('visits')
visits(@Req() request: FastifyRequest) {
const visits = request.session.get('visits');
request.session.set('visits', visits ? visits + 1 : 1);
return { visits: request.session.get('visits') };
}
@Get('visits-decorator')
visitsDecorator(@Session() session: secureSession.Session) {
const visits = session.get('visits');
session.set('visits', visits ? visits + 1 : 1);
return { visits: session.get('visits') };
}
@Post('logout')
logout(@Req() request: FastifyRequest) {
request.session.delete();
return { ok: true };
}
}
The Fastify session API is get(), set() and delete() instead of property access. The first request received
{ "visits": 1 } and a session=<encrypted>; Max-Age=28800; Path=/; HttpOnly; Secure; SameSite=Lax cookie. Two differences
from Express worth noting: the plugin did not withhold the Secure cookie on a plain-HTTP request, so verify your HTTPS
termination separately, and because the whole session travels in the cookie it is subject to the browser’s cookie size limit
and cannot be revoked server-side before it expires. With TypeScript, augmenting the plugin’s SessionData interface, as shown,
types the keys; otherwise get('visits') does not compile.
Pitfalls
| Symptom | Cause and fix |
|---|---|
Cookie lives 1000 times longer than intended |
|
|
No |
Application starts, signed cookies fail later |
|
Secure cookie never appears behind a proxy |
Express needs |
|
Invalid name or attribute combination (for example |
|
|
Sessions lost on restart or on another instance |
The default memory store was used; configure a shared store. |
Logged in as someone else after login |
Session identifier not regenerated on login (fixation); call |
What changed recently, and where older material is outdated
-
12.1 introduced the built-in cookie API (
@Cookies(),@SignedCookies(),setCookie(),clearCookie(), thecookiesapplication option andCookieSerializeOptions), together with built-in CSRF protection andapp.useSecurityHeaders(); see Security Headers, CORS & CSRF. Material written before 12.1, which includes the 2025 book listed in the bibliography, can only show thecookie-parser/@fastify/cookieroute, direct use ofrequest.cookiesand platform-specificres.cookie()/reply.setCookie()calls. Those still work, but they tie controllers to one platform and, in the Express case, use milliseconds formaxAge. -
Sessions are unchanged in principle: the official page still documents
express-sessionand@fastify/secure-session, and@Session()is still only an extractor. What older snippets get wrong today is mostly configuration: placeholder secrets left in code, the deprecated reliance on theresavedefault, the memory store in production, and noregenerate()at login. -
The
@nestjs/authenticationpackage (pre-1.0) offers sessions as part of a complete sign-in solution; its status and the Passport approach are compared in Authentication.