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

@Cookies()

cookie-parser (Express), @fastify/cookie (Fastify)

Read signed cookies

@SignedCookies() with the cookies.secret application option

cookie-parser with a secret (Express only)

Write and clear cookies

setCookie() / clearCookie() on the HTTP adapter

res.cookie() (Express), reply.setCookie() (Fastify)

Server-side sessions

@Session() parameter decorator only

express-session, @fastify/secure-session

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.

Reading all cookies, one cookie, and a cookie converted by a pipe (preferences.controller.ts)
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%20c became b c).

  • When the same name appears twice, the first occurrence wins, because browsers send the cookie with the most specific path first (the second a was ignored).

  • A named cookie that was not sent is undefined, so findTheme falls back to light.

  • @Cookies() does not verify anything: values come from the client and can be forged. Read anything you must trust with @SignedCookies(), covered below.

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 a Set-Cookie header

  • clearCookie(response, name, options?) appends a Set-Cookie header 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.

Setting and clearing a cookie (the second half of preferences.controller.ts)
  @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

path

Path attribute.

'/'

domain

Domain attribute; a valid host name, optionally with a leading dot. Omitted means a host-only cookie.

not set

maxAge

Max-Age attribute, in seconds; must be an integer.

not set

expires

Expires attribute as a Date; browsers prefer maxAge when both are set.

not set

httpOnly

HttpOnly: hides the cookie from client-side JavaScript.

false

secure

Secure: the browser sends the cookie only over HTTPS.

false

sameSite

'strict', 'lax' or 'none'; 'none' requires secure: true.

not set

partitioned

Partitioned attribute (CHIPS); requires secure: true.

false

priority

Non-standard Priority attribute honoured by Chromium: 'low', 'medium' or 'high'.

not set

signed

Signs the value with the first configured secret.

false

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.

Three attribute combinations (raw-cookie.controller.ts)
@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

HttpOnly

Always for session identifiers and tokens. A cross-site-scripting bug can then still make requests as the user but cannot exfiltrate the cookie.

Secure

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.

SameSite

lax is a sensible default for session cookies (sent on top-level navigations, withheld from cross-site sub-requests). strict withholds the cookie even when following a link from another site. none is only for cookies that must travel in cross-site contexts, such as an embedded widget, and Nest requires secure: true with it. SameSite reduces CSRF risk but does not replace a CSRF defence.

Partitioned

Opts a third-party cookie into per-top-level-site storage (CHIPS). Requires secure: true.

Domain

Leave it out for a host-only cookie. Setting it makes the cookie available to every subdomain, which widens the attack surface.

Path

Scopes the cookie to a URL prefix. It is a convenience, not a security boundary.

maxAge / expires

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)

  • path contains ; or a character outside printable ASCII

  • domain is not a valid host name (a leading dot is allowed)

  • maxAge is not an integer, or expires is not a valid Date

  • sameSite or priority is not an allowed value

  • sameSite is 'none', or partitioned is true, while secure is not true (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.

Bootstrap with a validated, comma-separated secret list (cookie-secrets.ts and bootstrap.ts)
// 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.

Signed, HttpOnly, Secure login cookie (auth.controller.ts)
@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.

Rotation: new secret first, previous secret kept until old cookies expire
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() with signed), cookie-parser or express-session keep verifying after you move to the built-in API, and cookie-parser can verify cookies signed by Nest

  • values signed by @fastify/cookie, which omits the s: prefix, verify as well

  • the opposite direction does not work: @fastify/cookie’s `unsignCookie() cannot verify cookies signed by Nest, because of the s: prefix

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() returns req.cookies as 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-parser moves signed cookies from req.cookies to req.signedCookies, and turns j:-prefixed JSON cookies into objects.

  • When the cookies.secret application option is set, @SignedCookies() always verifies the raw Cookie header against the Nest secrets and ignores req.signedCookies. Configure the same secrets as your middleware so the cookies it signed keep verifying.

  • Without cookies.secret, @SignedCookies() falls back to req.signedCookies populated by cookie-parser and treats the cookies it marked invalid as missing. @fastify/cookie does not populate req.signedCookies, so on Fastify @SignedCookies() requires the cookies.secret option.

npm i cookie-parser
npm i -D @types/cookie-parser
Applying cookie-parser (parser-setup.ts)
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
Registering the plugin (parser-setup.ts)
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 (express-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 (@fastify/secure-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:

sequenceDiagram autonumber participant B as Browser participant S as session middleware participant H as Nest handler participant ST as Session store B->>S: GET /session/visits (no cookie) S->>H: req.session (new, empty) H->>H: req.session.visits = 1 H-->>S: response S->>ST: save session (modified, so saved) S-->>B: 200 + Set-Cookie sid (signed id) B->>S: GET /session/visits (Cookie: sid) S->>ST: load by verified id ST-->>S: session data S->>H: req.session (visits = 1) H->>H: visits = 2 H-->>B: 200 (and save, since modified)
Figure 1. Session cookie round trip with express-session (identifier in the cookie, state in the store)

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.

Global session middleware configured from ConfigService (express-setup.ts)
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:

  • secret signs 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: false stops sessions that were not modified from being written back to the store. The default is true, but relying on it is deprecated because the default will change.

  • saveUninitialized: false does 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: true is 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 carrying X-Forwarded-Proto: https received sid=s%3A<id>.<signature>; Path=/; Expires=…​; HttpOnly; Secure; SameSite=Lax, while the same request over plain HTTP received no Set-Cookie header.

  • cookie.maxAge here is in milliseconds, unlike Nest’s built-in setCookie().

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:

Reading and writing the session, and the @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().

Regenerating the identifier on login, destroying the session on logout
@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.

Registering the plugin with a key from ConfigService (fastify-setup.ts)
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
    },
  });
}
Using the session in a Fastify controller (fastify-session.controller.ts)
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

maxAge copied from res.cookie() (milliseconds) into setCookie() (seconds).

@SignedCookies() always undefined or a 500

No cookies.secret (a 500), a different secret from the one that signed, or on Fastify relying on @fastify/cookie alone.

Application starts, signed cookies fail later

cookies.secret was undefined because an environment variable was missing; validate at startup as shown above.

Secure cookie never appears behind a proxy

Express needs trust proxy; the proxy must send X-Forwarded-Proto; the browser must be on HTTPS.

500 from setCookie()

Invalid name or attribute combination (for example sameSite: 'none' without secure); validate user-supplied names.

clearCookie() does nothing in the browser

path or domain differs from those used when the cookie was set.

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 regenerate().

What changed recently, and where older material is outdated

  • 12.1 introduced the built-in cookie API (@Cookies(), @SignedCookies(), setCookie(), clearCookie(), the cookies application option and CookieSerializeOptions), together with built-in CSRF protection and app.useSecurityHeaders(); see Security Headers, CORS & CSRF. Material written before 12.1, which includes the 2025 book listed in the bibliography, can only show the cookie-parser / @fastify/cookie route, direct use of request.cookies and platform-specific res.cookie() / reply.setCookie() calls. Those still work, but they tie controllers to one platform and, in the Express case, use milliseconds for maxAge.

  • Sessions are unchanged in principle: the official page still documents express-session and @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 the resave default, the memory store in production, and no regenerate() at login.

  • The @nestjs/authentication package (pre-1.0) offers sessions as part of a complete sign-in solution; its status and the Passport approach are compared in Authentication.