Authentication

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, published 2026-09-30), Node.js 22 and 24 LTS, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. It covers two ways to authenticate users. The first is @nestjs/authentication (npm version 0.0.1 on 2026-10-02), the first-party package that the NestJS documentation has taught since its September 2026 restructure; it is pre-1.0, so its API can still change between releases. The second is the established approach built on @nestjs/passport 12.0, Passport 0.7, passport-local 1.0, passport-jwt 4.0 and @nestjs/jwt 12.0, which the documentation moved to a recipe and which most existing NestJS applications use. The examples use the Nest 12 CLI project layout (ESM, Vitest and supertest), and the HTTP behaviour is the same on the Fastify adapter; the headers and bodies shown are what the application returns. The running scenario is the Bookshelf application that grows across this section.

Authentication concepts

Authentication answers "who is calling?". Authorization answers "what may they do?", and is the subject of Authorization. In NestJS the first one usually produces request.user, and the second one reads it, typically in a guard.

An API has to settle three questions before writing any code:

  • How does the client prove who it is? A password (hashed on the server, see Password hashing: scrypt, argon2 and bcrypt), a one-time code from an authenticator app, a link sent to an inbox, an identity provider such as Google, or a long-lived API key. The theory behind the identity-provider options is in OpenID Connect and Social Login and Federation; Authentication Methods: Passwordless and 2FA covers two-factor and magic links.

  • How does the server remember it? Either with a server-side session (a random token in an HttpOnly cookie that points at a record on the server, which can be revoked at once) or with tokens that the client sends on every call (a short-lived signed JWT, usually with a longer-lived refresh token). Sessions suit browsers; bearer tokens suit mobile apps and other servers. The trade-offs are discussed in Access and Refresh Tokens, JWT and the JOSE Family and Logout and Session Management.

  • Where is the check performed? In NestJS, in a guard. A guard runs after middleware and before interceptors, pipes and the handler (see Request Lifecycle and Guards), so an unauthenticated request never reaches validation or business code. Both approaches on this page register a global guard that rejects anonymous requests by default and lets you opt routes out.

This page does not re-explain OAuth 2.0 or JWT; it shows how NestJS packages implement them. OAuth Security Best Practices is the checklist to read next.

Sign-in, token or session, protected request

Both approaches follow the same shape: a public route turns credentials into a credential for later requests, and a global guard turns that credential back into a user.

sequenceDiagram autonumber participant B as Browser participant M as Mobile app participant G as Global guard participant S as Sign-in route participant H as Handler B->>G: POST /auth/sign-in (email, password) G->>S: route is @Public(): skip authentication S->>S: verify password hash S-->>B: 200 + Set-Cookie session (HttpOnly, Secure) B->>G: GET /me + Cookie G->>G: session provider loads session, then user G->>H: request.user is set H-->>B: 200 user M->>S: POST /auth/token (email, password) S-->>M: access token (15 min) + refresh token M->>G: GET /me + Authorization Bearer access token G->>G: JWT provider verifies signature, exp, iss, aud G->>H: request.user is set H-->>M: 200 user M->>S: POST /auth/token/refresh (refresh token) S-->>M: new access token + successor refresh token
Figure 1. Browser session and mobile token flows through the global guard

Where authentication sits in the request lifecycle

Authentication is a guard, so it runs after middleware (including cookie-parser, helmet or a CORS handler) and before interceptors, pipes and the handler. Global guards run first, in the order they were registered; guards of the controller and of the route follow. Two consequences matter in practice:

  • A request rejected by the authentication guard never reaches a ValidationPipe, so on a protected route a malformed body from an anonymous caller gets a 401, not a 400 (public routes such as sign-in do reach the pipe).

  • A global rate limiter registered before the authentication guard also counts anonymous requests, which is what you want for sign-in (see Rate-limited sign-in). Global guards run in module order and the root module comes first, so a guard in AppModule runs before the one a library module registers. @nestjs/authentication registers its own global guard and the documentation asks you to import it before @nestjs/authorization, because authorization needs the user that authentication sets.

The @nestjs/authentication package

@nestjs/authentication signs users in and tells the rest of the application who they are. It registers a global guard, gives you decorators and an injectable context to read the current user, and ships the parts every sign-in flow needs: server-side sessions, password hashing (scrypt), email verification and password reset links, TOTP two-factor authentication, magic links, OpenID Connect, access and refresh tokens, and API keys. It is built on Node.js built-ins. It keeps its state in your database through stores that you write against documented interfaces, and it does not read your users table: it asks your code for a user by id.

The package is pre-1.0. Names below match the official documentation and the installed package, but expect changes before 1.0. The established alternative is shown in The established approach: Passport and JWT.

npm i --save @nestjs/authentication
openssl rand -hex 32   # use the output as JWT_SECRET (and again, separately, as TOTP_ENCRYPTION_KEY)

The examples keep users in memory behind a UsersRepository class; replace its method bodies with TypeORM, Prisma or Drizzle and the rest only sees plain User objects. Sessions, tokens and pending links are kept in memory too until you register a store (see Stores, events, errors and production).

The user the application defines (the package never reads your table)
export type Role = 'reader' | 'editor' | 'admin';

export interface User {
  id: string;
  email: string;
  /** Whether the owner proved the address, e.g. with a verification link. */
  emailVerified: boolean;
  roles: Role[];
}
A repository that returns users without their password hashes
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { User } from './user.js';

interface UserRow extends User {
  passwordHash: string | null;
}

const normalize = (email: string) => email.trim().normalize('NFC').toLowerCase();
const toUser = ({ id, email, emailVerified, roles }: UserRow): User => ({ id, email, emailVerified, roles: [...roles] });

/** In-memory stand-in for the data layer. Only `User` objects (never hashes) leave this class. */
@Injectable()
export class UsersRepository {
  private readonly rows = new Map<string, UserRow>();

  async create(email: string, passwordHash: string | null = null, emailVerified = false): Promise<User> {
    const row: UserRow = { id: randomUUID(), email: normalize(email), emailVerified, roles: ['reader'], passwordHash };
    this.rows.set(row.id, row);
    return toUser(row);
  }

  async findById(id: string): Promise<User | null> {
    const row = this.rows.get(id);
    return row ? toUser(row) : null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const row = this.find(email);
    return row ? toUser(row) : null;
  }

  /** For password sign-in only. */
  async findCredentials(email: string) {
    const row = this.find(email);
    return row ? { user: toUser(row), passwordHash: row.passwordHash } : null;
  }

  async updatePasswordHash(id: string, passwordHash: string): Promise<void> {
    const row = this.rows.get(id);
    if (row) row.passwordHash = passwordHash;
  }

  /** Only while `email` is still the user's address: a link sent to an old one verifies nothing. */
  async markEmailVerified(id: string, email: string): Promise<boolean> {
    const row = this.rows.get(id);
    if (!row || row.email !== normalize(email)) return false;
    row.emailVerified = true;
    return true;
  }

  private find(email: string) {
    const normalized = normalize(email);
    return [...this.rows.values()].find((row) => row.email === normalized);
  }
}

Registering the module

AuthenticationModule has forRoot() and forRootAsync() variants. The options are plain values: lifetimes, secrets, URLs, client ids. No class goes in the options. Credential providers and handlers are ordinary @Injectable() classes of your own modules that register themselves from their constructor with AuthenticationRegistry. Use forRootAsync() as soon as a value comes from the environment: its factory runs at bootstrap, whereas the argument of forRoot() is evaluated when the file is imported. Lifetimes are milliseconds or strings such as '15m' and '14d'. @nestjs/jwt counts seconds instead, so a numeric lifetime under a minute logs a warning at startup.

The secrets come from ConfigService (see Configuration), never from literals in the source: getOrThrow() makes a missing variable stop the application at startup.

The module registration, trimmed to the options of this page
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    ThrottlerModule.forRoot([{ ttl: minutes(1), limit: 100 }]),
    AuthenticationModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        session: {
          absoluteTtl: '14d', // signed out after 14 days, however active
          idleTtl: '3d', // or after 3 days without a request
          metadata: (request) => ({ userAgent: request.headers['user-agent'] }),
        },
        accessToken: {
          key: config.getOrThrow<string>('JWT_SECRET'), // openssl rand -hex 32
          issuer: 'https://api.bookshelf.test',
          audience: 'bookshelf-mobile',
          ttl: '15m',
        },
        mfa: {
          issuer: 'bookshelf.test',
          encryption: { keys: [config.getOrThrow<string>('TOTP_ENCRYPTION_KEY')] },
        },
        emailVerification: { url: `${config.getOrThrow<string>('APP_URL')}/verify-email` },
        passwordReset: { url: `${config.getOrThrow<string>('APP_URL')}/reset-password` },
        magicLink: { url: `${config.getOrThrow<string>('APP_URL')}/sign-in/magic` },
        oidc: {
          callbackUrl: `${config.getOrThrow<string>('APP_URL')}/auth/oidc/:provider/callback`,
          providers: {
            google: google({
              clientId: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
              clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
            }),
          },
        },
      }),
    }),
  ],

The module also registers a global guard. From then on every route requires a signed-in user and answers 401 Unauthorized to anyone else, with a WWW-Authenticate: Bearer realm="bookshelf" challenge when a bearer provider is registered:

curl -i http://localhost:3000/me
{ "message": "Unauthorized", "statusCode": 401 }

With globalGuard: false the routes stay open until they use @UseGuards(AuthenticationGuard). Sessions are stored in memory until a provider registers a store, and with NODE_ENV=production the module refuses to start that way unless allowInMemoryStorage is set.

The options table below lists everything forRoot() accepts (defaults are those of the documentation). Lifetimes accept up to 100 years; a longer one, a value outside a documented range, or the NaN that Number() makes of an unset environment variable fails at startup.

Option Default Description

isGlobal, globalGuard

true, true

Register the module and AuthenticationGuard globally.

allowInMemoryStorage

false

Start with NODE_ENV=production while a contract a feature uses has no store.

session.cookieName

__Host-sid

The session cookie (sid when the cookie settings rule out the __Host- prefix).

session.absoluteTtl, session.idleTtl

'7d', '1d'

Maximum session lifetime however active, and the idle timeout (0 turns it off).

session.touchInterval

'1m'

How often activity is written to the store at most. Must be shorter than idleTtl.

session.cookie

secure: true, sameSite: 'lax', path: '/'

Cookie attributes (secure, sameSite, path, domain). HttpOnly is always set.

session.trustedOrigins

none

Other origins whose pages may use the session cookie for writes and sign in.

session.metadata

none

A function of the sign-in request whose result is stored with each new session.

password.logN

17

Base-2 logarithm of the scrypt cost N (10 to 22). r, p, keyLength and saltLength set the rest. A hash may use at most 1 GiB, so with r at 8, logN stops at 20.

accessToken.key

required

HS256 secret of at least 32 bytes, or a private key for RS256, ES256 or EdDSA (PEM or KeyObject).

accessToken.issuer, audience, ttl

none, none, '15m'

Written into and checked on every token.

refreshToken.ttl, refreshToken.absoluteTtl

'30d', '90d'

One refresh token, and a whole refresh-token family.

mfa.encryption

required with mfa

keys (AES-256-GCM, newest first), or false to store TOTP secrets in plaintext (tests only).

mfa.window, maxAttempts, lockoutWindow, recoveryCodes, pendingTtl

1, 5, '15m', 10, '10m'

Accepted 30-second steps either side of now, failed codes per window before a lockout, recovery codes per batch, and how long a session may wait for its second factor.

emailVerification, passwordReset, magicLink

url required

The web-app page the link opens. ttl defaults to '24h', '1h' and '15m'. magicLink.bindToBrowser defaults to true.

oidc

callbackUrl and providers required

Presets google(), github() and microsoft() (which also takes the directory’s tenant). redirectAfterLogin defaults to /, transactionTtl to '10m'.

The features emailVerification, passwordReset, magicLink and oidc also need a registered handler of the same name. A feature has two halves, the option and the handler, and one without the other fails at startup with a message naming the missing half.

Credential providers

A credential provider looks at an incoming request and returns the user the request proves, or null when it carries no credentials the provider understands. The guard asks the providers in ascending order and the first one that returns a user wins. The order is given at registration (default 0) rather than left to the order in which Nest constructs the providers.

Session cookie provider: validate() loads the user on every request
import { Injectable } from '@nestjs/common';
import { AuthenticationRegistry, SessionCookieProvider, type SessionRecord } from '@nestjs/authentication';
import type { User } from './user.js';
import { UsersRepository } from './users.repository.js';

@Injectable()
export class SessionAuth extends SessionCookieProvider<User> {
  constructor(
    private readonly usersRepository: UsersRepository,
    registry: AuthenticationRegistry,
  ) {
    super();
    registry.registerProvider(this); // order 0: asked first
  }

  // Called for every request with a live session cookie.
  // Returning null (the user was deleted) ends the session.
  validate(session: SessionRecord) {
    return this.usersRepository.findById(session.userId);
  }
}

Because validate() runs on every authenticated request, a deleted user loses access immediately, not when the session expires.

JWT bearer provider: verifies signature, exp, iss and aud, then loads the user
import { Injectable } from '@nestjs/common';
import { AuthenticationRegistry, JwtBearerProvider, type JwtClaims } from '@nestjs/authentication';
import type { User } from './user.js';
import { UsersRepository } from './users.repository.js';

@Injectable()
export class JwtAuth extends JwtBearerProvider<User> {
  constructor(
    private readonly usersRepository: UsersRepository,
    registry: AuthenticationRegistry,
  ) {
    // The key, issuer and audience come from the `accessToken` module options.
    super({ realm: 'bookshelf' });
    registry.registerProvider(this, { order: 1 });
  }

  validate({ sub }: JwtClaims) {
    return sub ? this.usersRepository.findById(sub) : null;
  }
}

With a secret as the key, tokens are signed with HS256. That suits an API that both issues and verifies its tokens. If other services verify them too, make the accessToken key an ES256 private key so that they can verify with the public half. To accept tokens from another issuer, pass super() its jwks URL or public key together with issuer and audience, both required.

Sign-up and sign-in

PasswordHasher hashes with scrypt (crypto.scrypt on the libuv thread pool, so it does not block the event loop) at the parameters OWASP recommends (N=2^17, r=8, p=1, about 128 MiB per hash). Every hash records its own parameters, needsRehash() tells you when stored hashes are weaker than the current settings, and verify() given no stored hash checks a dummy one so that an unknown email and a wrong password take the same time.

Registering and verifying credentials
import { ConflictException, Injectable } from '@nestjs/common';
import { PasswordHasher } from '@nestjs/authentication';
import type { User } from './user.js';
import { UsersRepository } from './users.repository.js';

@Injectable()
export class CredentialsService {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly passwordHasher: PasswordHasher,
  ) {}

  async register(email: string, password: string): Promise<User> {
    if (await this.usersRepository.findByEmail(email)) {
      throw new ConflictException('Email already registered');
    }
    // Unverified: anyone can type any address into a sign-up form.
    return this.usersRepository.create(email, await this.passwordHasher.hash(password));
  }

  /** The user, or `null` when the email or the password is wrong. */
  async verify(email: string, password: string): Promise<User | null> {
    const found = await this.usersRepository.findCredentials(email);
    // With no account, verify() checks a dummy hash: same timing, same answer.
    const valid = await this.passwordHasher.verify(password, found?.passwordHash ?? undefined);
    if (!valid || !found?.passwordHash) {
      return null;
    }
    if (this.passwordHasher.needsRehash(found.passwordHash)) {
      await this.usersRepository.updatePasswordHash(found.user.id, await this.passwordHasher.hash(password));
    }
    return found.user;
  }
}
Request bodies validated with class-validator (a Standard Schema object also works)
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class SignUpDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(12)
  @MaxLength(128)
  password: string;
}

export class SignInDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

export class EmailDto {
  @IsEmail()
  email: string;
}

/** The mobile app's sign-in: a password, and a code once the user has an authenticator. */
export class TokenRequestDto extends SignInDto {
  @IsOptional()
  @IsString()
  code?: string;

  @IsOptional()
  @IsString()
  recoveryCode?: string;
}

export class ResetPasswordDto {
  @IsString()
  token: string;

  @IsString()
  @MinLength(12)
  @MaxLength(128)
  password: string;
}

Nest 12.1 validates any Standard Schema object (Zod, Valibot, ArkType) through StandardSchemaValidationPipe and the schema option of @Body(); bind either ValidationPipe or StandardSchemaValidationPipe globally, not both. See Pipes & Validation.

SignInService.signIn() is the one place where a first factor becomes a session. It creates the session, sets the cookie on the response itself (so the controller needs neither @Req() nor @Res(), on Express and Fastify alike), deletes the session this browser had before (defence against session fixation) and refuses, with a 403, a sign-in posted from another website’s page (login CSRF).

Controller: @Public() on the class skips authentication for both routes
import { Body, Controller, HttpCode, Post, UnauthorizedException } from '@nestjs/common';
import { EmailVerificationService, Public, SignInService } from '@nestjs/authentication';
import { Throttle, minutes } from '@nestjs/throttler';
import { SignInDto, SignUpDto } from './auth.dto.js';
import { CredentialsService } from './credentials.service.js';

@Public()
@Controller('auth')
export class AuthController {
  constructor(
    private readonly credentialsService: CredentialsService,
    private readonly signInService: SignInService,
    private readonly emailVerificationService: EmailVerificationService,
  ) {}

  @Post('sign-up')
  async signUp(@Body() body: SignUpDto) {
    const user = await this.credentialsService.register(body.email, body.password);
    await this.signInService.signIn(user.id, { method: 'password' });
    await this.emailVerificationService.send(user); // the link proves the address
    return user;
  }

  @Throttle({ default: { limit: 5, ttl: minutes(15) } })
  @Post('sign-in')
  @HttpCode(200)
  async signIn(@Body() body: SignInDto) {
    const user = await this.credentialsService.verify(body.email, body.password);
    if (!user) {
      throw new UnauthorizedException('Invalid email or password');
    }
    const { session } = await this.signInService.signIn(user.id, { method: 'password' });
    return { mfaRequired: session.mfa === 'pending' };
  }
}

The cookie set on sign-up:

Set-Cookie: __Host-sid=<token>; Max-Age=1209600; Path=/; HttpOnly; Secure; SameSite=Lax
  • The token is random and 256 bits wide; only its SHA-256 hash is stored, so a leaked session store cannot be replayed as cookies.

  • The __Host- prefix makes browsers accept the cookie only from this host. Chrome, Firefox and curl treat http://localhost as a secure origin, so development needs no HTTPS. On another host over plain HTTP set cookie.secure to false (the cookie is then named sid).

  • Besides SameSite=Lax, the module treats POST, PUT, PATCH and DELETE requests and WebSocket handshakes that come from another origin (judged by Sec-Fetch-Site, Origin and Host) as anonymous. Clients that send neither header, such as curl and mobile apps, are unaffected. List a web app on another origin in session.trustedOrigins, and on Nest 12.1 or later also call app.enableCsrfProtection() (see Security Headers, CORS & CSRF). For plain cookies and express-session see Cookies & Sessions.

curl -i -c jar.txt -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"<at least 12 characters>"}' http://localhost:3000/auth/sign-up
curl -b jar.txt http://localhost:3000/me
{ "id": "63ab04ac-19e5-4ac5-876f-dde9cc75b584", "email": "ada@example.com", "emailVerified": false, "roles": ["reader"] }

A body that fails validation is a 400 from the validation pipe, as with any other route:

{ "message": ["email must be an email", "password must be longer than or equal to 12 characters"], "error": "Bad Request", "statusCode": 400 }

Email verification, password reset and magic links

Three features use the same pattern: the package creates and checks single-use tokens (it stores the SHA-256 of a 256-bit token), and a handler that you register delivers the link by email and applies the result to your users table. The page the link opens belongs to the web app and POSTs the token back to the API, because email scanners and link previews follow links with GET and would use up the token before the user clicks.

The documentation sends links with @nestjs/mail (see i18n, Mail & File Storage). To keep the examples self-contained, the handlers here push the link into a small LinkOutbox service that the test reads; with the mail package the send() body becomes await this.mailer.send(VerifyEmailMail, { to: link.email, data: link }).

A stand-in for the mail library
import { Injectable } from '@nestjs/common';

/** Stand-in for a mail library: keeps the last link sent to each address. */
@Injectable()
export class LinkOutbox {
  readonly sent: { kind: string; email: string; url: string }[] = [];

  push(kind: string, email: string, url: string) {
    this.sent.push({ kind, email, url });
  }

  last(kind: string, email: string) {
    return [...this.sent].reverse().find((m) => m.kind === kind && m.email === email);
  }
}
The three handlers: each registers itself under the name of the feature it serves
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
  AuthenticationRegistry,
  EmailVerificationHandler,
  MagicLinkHandler,
  PasswordResetHandler,
  type EmailVerificationLink,
  type MagicLink,
  type PasswordResetLink,
} from '@nestjs/authentication';
import { LinkOutbox } from './link-outbox.js';
import { UsersRepository } from './users.repository.js';

@Injectable()
export class EmailVerificationMailer extends EmailVerificationHandler {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly outbox: LinkOutbox,
    registry: AuthenticationRegistry,
  ) {
    super();
    registry.registerHandler('emailVerification', this);
  }

  // With @nestjs/mail this is `await this.mailer.send(VerifyEmailMail, { to: link.email, data: link })`.
  send(link: EmailVerificationLink) {
    this.outbox.push('verify', link.email, link.url);
  }

  // Runs when the link is used; verifies nothing if the address changed since.
  markVerified(userId: string, email: string) {
    return this.usersRepository.markEmailVerified(userId, email);
  }
}

@Injectable()
export class PasswordResetMailer extends PasswordResetHandler {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly outbox: LinkOutbox,
    registry: AuthenticationRegistry,
  ) {
    super();
    registry.registerHandler('passwordReset', this);
  }

  // The link goes to the stored address, and works only while address and hash stay the same.
  async findUser(email: string) {
    const found = await this.usersRepository.findCredentials(email);
    return found && { id: found.user.id, email: found.user.email, passwordHash: found.passwordHash };
  }

  send(link: PasswordResetLink) {
    this.outbox.push('reset', link.email, link.url);
  }

  updatePassword(userId: string, passwordHash: string) {
    return this.usersRepository.updatePasswordHash(userId, passwordHash);
  }
}

@Injectable()
export class MagicLinkMailer extends MagicLinkHandler {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly outbox: LinkOutbox,
    registry: AuthenticationRegistry,
  ) {
    super();
    registry.registerHandler('magicLink', this);
  }

  send(link: MagicLink) {
    this.outbox.push('magic', link.email, link.url);
  }

  // Runs when the link is used, which proves the address.
  async resolveUser(email: string) {
    const user = await this.usersRepository.findByEmail(email);
    if (!user) {
      return this.usersRepository.create(email, null, true); // passwordless sign-up; return null to refuse
    }
    if (!user.emailVerified) {
      // Whoever chose that password may not own the address.
      throw new ForbiddenException('Sign in with your password, or reset it');
    }
    return user;
  }
}

markVerified() marks only the address the link was sent to: if the user changed address since, the link verifies nothing. findUser() of the reset handler returns the stored address and hash, and a reset link works only while both are unchanged. resolveUser() of the magic-link handler returns null to refuse unknown addresses; here it creates a verified account, and it refuses with a 403 an account whose address nobody confirmed, so that whoever chose that password cannot be handed a pre-hijacked account.

Controllers: the token is POSTed; request() is not awaited so the reset answer cannot leak which emails exist
import { BadRequestException, Body, ConflictException, Controller, HttpCode, Post, UnauthorizedException } from '@nestjs/common';
import {
  CurrentUser,
  EmailVerificationService,
  MagicLinkService,
  PasswordResetService,
  Public,
} from '@nestjs/authentication';
import { Throttle, minutes } from '@nestjs/throttler';
import { EmailDto, ResetPasswordDto } from './auth.dto.js';
import type { User } from './user.js';

@Controller('auth/email')
export class EmailVerificationController {
  constructor(private readonly emailVerificationService: EmailVerificationService) {}

  // The page the link opens POSTs the token: scanners follow links with GET and would spend it.
  @Public()
  @Post('verify')
  @HttpCode(200)
  async verify(@Body('token') token: string) {
    const verified = await this.emailVerificationService.verify(token);
    if (!verified) {
      throw new BadRequestException('Invalid or expired link');
    }
    return { email: verified.email, emailVerified: true };
  }

  @Post('verification')
  @HttpCode(202)
  async resend(@CurrentUser() user: User) {
    if (user.emailVerified) {
      throw new ConflictException('Email address already verified');
    }
    await this.emailVerificationService.send(user);
  }
}

@Public()
@Controller('auth/password')
export class PasswordResetController {
  constructor(private readonly passwordResetService: PasswordResetService) {}

  // Same answer, as fast, whether or not the address has an account: do not await request().
  @Throttle({ default: { limit: 3, ttl: minutes(15) } })
  @Post('forgot')
  @HttpCode(202)
  forgot(@Body() body: EmailDto) {
    this.passwordResetService.request(body.email);
  }

  @Post('reset')
  @HttpCode(200)
  async reset(@Body() body: ResetPasswordDto) {
    const result = await this.passwordResetService.reset(body.token, body.password, { signIn: true });
    if (!result) {
      throw new BadRequestException('Invalid or expired link');
    }
    return { mfaRequired: result.signedIn?.session.mfa === 'pending' };
  }
}

@Public()
@Controller('auth/magic-link')
export class MagicLinkController {
  constructor(private readonly magicLinkService: MagicLinkService) {}

  @Throttle({ default: { limit: 3, ttl: minutes(15) } })
  @Post()
  @HttpCode(202)
  async request(@Body() body: EmailDto, @Body('redirectTo') redirectTo?: string) {
    await this.magicLinkService.create(body.email, { redirectTo });
  }

  @Post('consume')
  @HttpCode(200)
  async consume(@Body('token') token: string) {
    // A link opened in another browser throws MagicLinkError: a 401 `not_this_browser`.
    const result = await this.magicLinkService.consume(token);
    if (!result) {
      throw new UnauthorizedException('Invalid or expired link');
    }
    return { redirectTo: result.redirectTo ?? '/', mfaRequired: result.session.mfa === 'pending' };
  }
}
  • A verification link works once, for 24 hours, and only for the address it was sent to. verify() returns null for an unknown, used or expired token and the route answers 400. The resend route is not public: only a signed-in user can ask for a new link, and only to their own address.

  • PasswordResetService.request() returns before it looks the address up, so a request for an unregistered address gets the same 202 in the same time. A reset link works once, for an hour. reset() stores the new hash, invalidates the user’s other reset links and ends every session and refresh token of the account; with { signIn: true } it also signs the user in. API keys live in your own table and stay valid, so revoke them on the password-reset event.

  • A magic link is valid for 15 minutes and usable once, and it works only in the browser that requested it: create() sets a __Host-magic_link_tx cookie and consume() refuses the token without it. Opened elsewhere, it answers:

{ "message": "Open the link in the browser you requested it from, or request a new one here", "error": "not_this_browser", "statusCode": 401 }

Because a link proves the address, reset() and the magic link mark it verified. An address that is not yet verified gates some features: see verifiedEmail below.

Reading the current user

@CurrentUser() injects the user that validate() loaded, @CurrentUser('email') a single property, and @CurrentSession() what the provider considers the session. @Public() skips authentication, and @Authenticate() adjusts how a route authenticates.

Decorators on a controller
import { Controller, Get } from '@nestjs/common';
import { Authenticate, CurrentUser, Public } from '@nestjs/authentication';
import type { User } from './user.js';

@Controller()
export class MeController {
  @Get('me')
  me(@CurrentUser() user: User) {
    return user;
  }

  // `@CurrentUser('key')` injects one property.
  @Get('me/email')
  email(@CurrentUser('email') email: string) {
    return { email };
  }

  // Only verified addresses: anyone else signed in gets 403 `email_unverified`.
  @Authenticate({ verifiedEmail: true })
  @Get('me/orders')
  orders() {
    return [];
  }

  // Anonymous callers are let through with a null user; bad credentials still get 401.
  @Authenticate({ optional: true })
  @Get('hello')
  hello(@CurrentUser() user: User | null) {
    return { hello: user?.email ?? 'guest' };
  }

  @Public()
  @Get('health')
  health() {
    return { status: 'ok' };
  }
}

@Authenticate() takes four options, on a class or a method:

Option Effect

optional

Let anonymous callers through with null as the user. Bad credentials still get a 401.

mfa

Require a verified second factor: a verified session, or a JWT whose amr claim contains mfa, otp or hwk. API keys never satisfy it.

verifiedEmail

Require a verified address, or answer 403 with error: email_unverified.

providers

Only these provider classes (or subclasses) may authenticate the route.

The verifiedEmail rejection is a 403, not a 401, because signing in again would not help:

{ "message": "Email address not verified", "error": "email_unverified", "statusCode": 403 }

Controllers are not the only code that needs the user. AuthenticationContext is a singleton that reads the current request’s user from an AsyncLocalStorage the module opens around each handler, so requireUser() and .user are correct in anything a handler calls or awaits. requireUser() throws an AuthenticationError, which the module answers with a 401 on any transport. Outside a request (a queue worker, a cron job) there is no user: run the code as one with run().

Mobile tokens

A mobile app authenticates with bearer tokens: a short-lived JWT access token in the Authorization header, renewed with a long-lived rotating refresh token. TokenService.issue() signs the access token with the accessToken options, starts a refresh token and returns both, with expiresIn in seconds. Refresh tokens are opaque and stored as hashes. Each works once: refresh() spends it and returns its successor. The successors of one sign-in form a family; if a spent token comes back, two parties hold it, so the service revokes the whole family and both have to sign in again.

Issue, refresh and revoke
import { Body, Controller, HttpCode, Post, UnauthorizedException } from '@nestjs/common';
import { Public, TokenService } from '@nestjs/authentication';
import { Throttle, minutes } from '@nestjs/throttler';
import { TokenRequestDto } from './auth.dto.js';
import { CredentialsService } from './credentials.service.js';

@Public()
@Controller('auth/token')
export class TokensController {
  constructor(
    private readonly credentialsService: CredentialsService,
    private readonly tokenService: TokenService,
  ) {}

  @Throttle({ default: { limit: 5, ttl: minutes(15) } })
  @Post()
  @HttpCode(200)
  async issue(@Body() body: TokenRequestDto) {
    const user = await this.credentialsService.verify(body.email, body.password);
    if (!user) {
      throw new UnauthorizedException('Invalid email or password');
    }
    return this.tokenService.issue(user.id, {
      method: 'password',
      claims: { amr: ['pwd'] }, // copied into every refreshed access token
      secondFactor: { code: body.code, recoveryCode: body.recoveryCode }, // checked only with an authenticator
    });
  }

  // Each refresh token works once; an unknown, expired or reused one gets a 401.
  @Post('refresh')
  @HttpCode(200)
  refresh(@Body('refreshToken') refreshToken: string) {
    return this.tokenService.refresh(refreshToken);
  }

  // Ends the token's family. Unknown tokens get the same answer, as in RFC 7009.
  @Post('revoke')
  @HttpCode(204)
  async revoke(@Body('refreshToken') refreshToken: string) {
    await this.tokenService.revoke(refreshToken);
  }
}

POST /auth/token returns accessToken, refreshToken and expiresIn (900 for 15 minutes); the access token works on /me as Authorization: Bearer <accessToken>; refreshing returns a new pair; presenting the first refresh token a second time answers 401 and kills its successor, which then also answers 401.

  • claims are stored with the refresh-token family and copied into every access token it mints. The amr claim records how the user signed in, and two-factor authentication relies on it. Keep facts that can change, such as roles, out of claims: they would outlive the change. validate() loads the current user on every request instead.

  • Access tokens that were already issued stay valid until they expire (15 minutes here); that is the price of stateless verification, and the reason to keep them short.

  • JwtAuth leaves keys to ApiKeyAuth: a JWT contains two dots and an API key none.

Managing sessions

Sessions live on the server, so users can see where they are signed in and end any of those sessions. session.metadata (set in the module options above) stores the user agent with each new session, whichever way the user signed in.

List, revoke, sign out, sign out everywhere
import { Controller, Delete, Get, HttpCode, NotFoundException, Param, Post, UnauthorizedException } from '@nestjs/common';
import { CurrentSession, CurrentUser, Public, SessionService, SignInService, type SessionRecord } from '@nestjs/authentication';

@Controller('auth')
export class SessionsController {
  constructor(
    private readonly sessionService: SessionService,
    private readonly signInService: SignInService,
  ) {}

  @Get('sessions')
  async list(@CurrentUser('id') userId: string, @CurrentSession() current: SessionRecord) {
    const sessions = await this.sessionService.list(userId);
    return sessions.map(({ id, createdAt, lastActiveAt, metadata }) => ({
      id,
      createdAt,
      lastActiveAt,
      userAgent: metadata?.userAgent ?? null,
      current: id === current.id,
    }));
  }

  // revoke() only ends a session of this user: another user's id is a 404.
  @Delete('sessions/:id')
  @HttpCode(204)
  async revoke(@CurrentUser('id') userId: string, @Param('id') id: string) {
    if (!(await this.sessionService.revoke(id, { userId }))) {
      throw new NotFoundException();
    }
  }

  // Public so that a session still waiting for its second factor can sign out too.
  @Public()
  @Post('sign-out')
  @HttpCode(204)
  async signOut() {
    if (!(await this.signInService.signOut())) {
      throw new UnauthorizedException(); // no session cookie, e.g. a bearer token
    }
  }

  @Post('sign-out-everywhere')
  @HttpCode(204)
  async signOutEverywhere(@CurrentUser('id') userId: string) {
    await this.signInService.signOutEverywhere(userId);
  }
}

Session ids are SHA-256 hashes of the cookie tokens, so showing them to their owner is safe. revoke() takes the user as well as the id, so an id from the URL cannot end another user’s session. sign-out is public so that a session still waiting for its second factor can end too. signOutEverywhere() ends every session and refresh token of the user. Rotate the session whenever privileges change with SignInService.rotateSession().

[
  { "id": "<session id, 43 characters>", "createdAt": "2026-10-02T16:23:36.322Z", "lastActiveAt": "2026-10-02T16:23:36.322Z", "userAgent": null, "current": true },
  { "id": "<another session id>", "createdAt": "2026-10-02T16:23:36.330Z", "lastActiveAt": "2026-10-02T16:23:36.330Z", "userAgent": "phone", "current": false }
]

Two-factor authentication

Users can protect an account with an authenticator app (time-based one-time passwords, TOTP). Configure mfa in the module options (shown above). The server must read a TOTP secret back to check codes, so unlike a password it cannot be hashed: MfaService encrypts each secret with AES-256-GCM before it reaches storage, bound to its user. The key is required (mfa without encryption throws at startup) and is a string of at least 32 characters of random data, for example the output of openssl rand -hex 32. To rotate it, put the new key first in keys and keep the old one after it.

Enroll, confirm, verify and a step-up route
import { Body, Controller, HttpCode, Post, UnauthorizedException } from '@nestjs/common';
import { Authenticate, CurrentUser, MfaService, Public, SignInService } from '@nestjs/authentication';
import type { User } from './user.js';

@Controller('auth/mfa')
export class MfaController {
  constructor(
    private readonly mfaService: MfaService,
    private readonly signInService: SignInService,
  ) {}

  // Returns the otpauth:// URI for a QR code, and the secret for people who type it in.
  @Post('enroll')
  enroll(@CurrentUser() user: User) {
    return this.mfaService.enroll(user.id, user.email);
  }

  // The first code proves the authenticator works; recovery codes are shown once.
  @Post('confirm')
  @HttpCode(200)
  async confirm(@CurrentUser('id') userId: string, @Body('code') code: string) {
    if (!(await this.signInService.confirmMfa(userId, code))) {
      throw new UnauthorizedException('Invalid code');
    }
    return { recoveryCodes: await this.mfaService.generateRecoveryCodes(userId) };
  }

  // Second step of signing in. A pending session counts as signed in nowhere, so the route is public.
  @Public()
  @Post('verify')
  @HttpCode(200)
  async verify(@Body('code') code?: string, @Body('recoveryCode') recoveryCode?: string) {
    const issued = await this.signInService.completeMfa({ code, recoveryCode });
    if (!issued) {
      throw new UnauthorizedException('Invalid code');
    }
    return { mfa: issued.session.mfa };
  }

  // Step-up authentication: a verified second factor is required to get here.
  @Authenticate({ mfa: true })
  @Post('sensitive')
  @HttpCode(200)
  sensitive() {
    return { ok: true };
  }
}
  • enroll() returns secret and an otpauth:// uri for a QR code. confirm activates the authenticator once it produces a valid code, so a mistyped secret cannot lock anyone out, and returns ten recovery codes, shown once.

  • From then on signIn() creates pending sessions. A pending session is signed in nowhere: protected routes answer 401 with error: mfa_required, which tells the web app to ask for a code. That is why verify is @Public(). completeMfa() reads the session from this browser’s cookie, checks a code or a recovery code and rotates the session to a new id with mfa: 'verified'.

  • @Authenticate({ mfa: true }) is step-up authentication: the route needs a verified second factor even for a signed-in user.

  • The bearer-token path cannot bypass the second factor: TokenService.issue() gives a user with a confirmed authenticator no tokens without a code and answers 401 with mfa_required; the app sends the password again with secondFactor.

{ "message": "Second factor required", "error": "mfa_required", "statusCode": 401 }

Codes are accepted 30 seconds either side of now and each works once. After five wrong codes in 15 minutes the account refuses every code, right ones included, until the window passes; each attempt is counted before it is checked, so parallel guesses do not get around the limit.

"Sign in with Google" (OpenID Connect)

Create an OAuth client of type Web application in the Google Cloud console, register the callback URL, and put GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment (they reach the module through ConfigService, as in the options above). OidcService.start() builds the redirect to Google using the authorization code flow with PKCE, a state and a nonce; the login transaction is stored server-side and bound to the browser by a __Host-oidc_tx cookie. finish() handles the callback: it checks the state, exchanges the code, verifies the ID token’s signature against the provider’s published keys and its issuer, audience, expiry and nonce, and then asks your OidcAccountResolver which local user the Google account belongs to.

The account resolver and the two routes
import { Controller, ForbiddenException, Get, Injectable, Param, Query, Redirect, ConflictException } from '@nestjs/common';
import {
  AuthenticationRegistry,
  OidcAccountResolver,
  OidcService,
  Public,
  type OAuthTokens,
  type OidcProfile,
  type OidcResolveContext,
} from '@nestjs/authentication';
import { UsersRepository } from './users.repository.js';

@Injectable()
export class AccountLinker extends OidcAccountResolver {
  /** `provider:subject` -> user id. */
  private readonly identities = new Map<string, string>();

  constructor(
    private readonly usersRepository: UsersRepository,
    registry: AuthenticationRegistry,
  ) {
    super();
    registry.registerHandler('oidc', this);
  }

  async resolveUser(profile: OidcProfile, _tokens: OAuthTokens, { linkTo }: OidcResolveContext) {
    const key = `${profile.provider}:${profile.subject}`;
    const linkedId = this.identities.get(key);

    if (linkTo) {
      // "Connect Google" from the account page.
      if (linkedId && linkedId !== linkTo.id) throw new ConflictException('Linked to another user');
      this.identities.set(key, linkTo.id);
      return this.usersRepository.findById(linkTo.id);
    }
    if (linkedId) return this.usersRepository.findById(linkedId);

    // First visit: trust only an address the provider verified.
    if (!profile.email || !profile.emailVerified) return null; // 403

    const existing = await this.usersRepository.findByEmail(profile.email);
    if (!existing) {
      const user = await this.usersRepository.create(profile.email, null, true);
      this.identities.set(key, user.id);
      return user;
    }
    // Link by email only if the local account's owner proved the address too (pre-hijacking).
    if (!existing.emailVerified) {
      throw new ForbiddenException('Sign in with your password, then connect the provider from your account');
    }
    this.identities.set(key, existing.id);
    return existing;
  }
}

@Public()
@Controller('auth/oidc')
export class OidcController {
  constructor(private readonly oidcService: OidcService) {}

  @Get(':provider/login')
  @Redirect()
  login(@Param('provider') provider: string, @Query('redirectTo') redirectTo?: string, @Query('link') link?: string) {
    return this.oidcService.start(provider, { redirectTo, link: link === 'true' });
  }

  @Get(':provider/callback')
  @Redirect()
  callback(@Param('provider') provider: string, @Query() query: Record<string, string>) {
    return this.oidcService.finish(provider, query);
  }
}

Store the link as a (provider, subject) pair: emails change, Google’s subject id does not. Linking by email needs both sides to have verified the address: Google (profile.emailVerified), or anyone could create a Google account with a victim’s address; and the local account (existing.emailVerified), or someone who signed up first with the victim’s address and a password of their own would keep access after the victim signs in with Google (account pre-hijacking). Returning null refuses the sign-in with a 403. The "Connect Google" button points to GET /auth/oidc/google/login?link=true, which only starts for a fully signed-in session. Both routes use Nest’s HTTP adapter for cookies and redirects, so they work on Express and Fastify without @Req() or @Res(). The github() and microsoft() presets work the same way.

On the first call the discovery document is fetched from Google; GET /auth/oidc/google/login answers 302 to https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=…​;. The callback leg needs a real Google account, so test the account resolver against the documented contract with your own provider credentials.

API keys

Scripts and partner systems cannot fill in a sign-in form, and a refresh token that changes on every use is a poor fit for a nightly job. Give them API keys instead: long-lived secrets that a user creates in the web app and revokes when no longer needed. The script sends its key as a bearer token and acts as the user who created it. A key reads prefix_<id>_<secret>: a prefix (1 to 16 lowercase letters or digits) that tells secret scanners what they found, a public id of 16 hex characters and a secret of 256 random bits. The provider compares the SHA-256 of the whole key with the stored hash in constant time, and refuses it from expiresAt on. A slow password hash would add nothing here, since nobody guesses 256 random bits.

The provider only finds a key by its id; the table keeps hashes, never keys
import { Injectable } from '@nestjs/common';
import { ApiKeyProvider, AuthenticationRegistry } from '@nestjs/authentication';
import type { User } from './user.js';
import { UsersRepository } from './users.repository.js';

export interface StoredApiKey {
  id: string;
  userId: string;
  hash: string;
  createdAt: Date;
  expiresAt: Date;
}

@Injectable()
export class ApiKeyAuth extends ApiKeyProvider<User> {
  /** In-memory stand-in for an `api_keys` table: it keeps the SHA-256 of each key, never the key. */
  readonly keys = new Map<string, StoredApiKey>();

  constructor(
    private readonly usersRepository: UsersRepository,
    registry: AuthenticationRegistry,
  ) {
    // Keys look like shelf_<id>_<secret>. The prefix tells secret scanners what they found.
    super({ prefix: 'shelf', realm: 'bookshelf' });
    registry.registerProvider(this, { order: 2 });
  }

  // Called with the id part of a key; the provider then checks the hash and the expiry.
  async findKey(id: string) {
    const key = this.keys.get(id);
    const user = key && (await this.usersRepository.findById(key.userId));
    return user ? { hash: key.hash, user, expiresAt: key.expiresAt } : null;
  }
}
Create, list and revoke; the controller accepts the session provider only, so a leaked key cannot mint more keys
import { Controller, Delete, Get, HttpCode, NotFoundException, Param, Post } from '@nestjs/common';
import { Authenticate, CurrentUser } from '@nestjs/authentication';
import { ApiKeyAuth } from './api-key-auth.provider.js';
import { SessionAuth } from './session-auth.provider.js';

const ONE_YEAR = 365 * 24 * 60 * 60 * 1000;

// Managed from the web app with a session: a key can neither create nor revoke keys.
@Authenticate({ providers: [SessionAuth] })
@Controller('auth/api-keys')
export class ApiKeysController {
  constructor(private readonly apiKeyAuth: ApiKeyAuth) {}

  // The only response that contains the key: the table keeps its hash.
  @Post()
  create(@CurrentUser('id') userId: string) {
    const { key, id, hash } = this.apiKeyAuth.generate();
    const createdAt = new Date();
    const expiresAt = new Date(createdAt.getTime() + ONE_YEAR);
    this.apiKeyAuth.keys.set(id, { id, userId, hash, createdAt, expiresAt });
    return { id, key, createdAt, expiresAt };
  }

  @Get()
  list(@CurrentUser('id') userId: string) {
    return [...this.apiKeyAuth.keys.values()]
      .filter((k) => k.userId === userId)
      .map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
  }

  @Delete(':id')
  @HttpCode(204)
  revoke(@CurrentUser('id') userId: string, @Param('id') id: string) {
    const key = this.apiKeyAuth.keys.get(id);
    if (!key || key.userId !== userId) {
      throw new NotFoundException();
    }
    this.apiKeyAuth.keys.delete(id);
  }
}

The Map stands in for the api_keys table that the documentation creates with Drizzle (or TypeORM). generate() returns key, id and hash; create is the only time anyone sees the key. A key looks like shelf_620986e78f1e73ea_<43 URL-safe characters>. A key signs its owner in on every route that does not name its providers, but it never counts as a second factor. In a handler @CurrentSession() holds the key’s keyId with method set to api-key, which suits an audit log.

Rate-limited sign-in

@nestjs/authentication does not limit request rates; that is the job of @nestjs/throttler (see Rate Limiting & Encryption). Throttling by IP alone is too coarse for sign-in: one client can try passwords for many accounts, and users behind one network address share a limit. Track the email and the IP together wherever the request body names an account.

A throttler guard that tracks (email, IP); it also supports GraphQL requests
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { ThrottlerGuard } from '@nestjs/throttler';

/** Per IP everywhere; per (email, IP) where the request body names an account. */
@Injectable()
export class AuthThrottlerGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    const ip = await super.getTracker(req);
    const email = typeof req.body?.email === 'string' ? req.body.email.trim().normalize('NFC').toLowerCase() : '';
    return email ? `${email}|${ip}` : ip;
  }

  // GraphQL operations carry the HTTP request on the GraphQL context.
  getRequestResponse(context: ExecutionContext) {
    if (context.getType<GqlContextType>() === 'graphql') {
      const { req, res } = GqlExecutionContext.create(context).getContext();
      return { req, res };
    }
    return super.getRequestResponse(context);
  }
}

Register ThrottlerModule.forRoot([{ ttl: minutes(1), limit: 100 }]) and the guard as an APP_GUARD in the root module (both are in the module shown earlier), then tighten the routes that check a password or send an email, as AuthController, TokensController and the account controllers above do:

@Throttle({ default: { limit: 5, ttl: minutes(15) } })
@Post('sign-in')

Five wrong sign-ins for one email answer 401, the sixth answers 429, while another email from the same IP is still answered normally. The guard sits in the root module, so it runs before the authentication guard and guessed bearer tokens count against the limit too. Two-factor codes need no throttle of their own (the package locks an account’s codes after five failures) and neither does the reset route (a guessed token costs one lookup against a 256-bit space). Behind a proxy enable trust proxy so the tracker sees client addresses, and use shared storage such as Redis for several instances.

GraphQL and WebSockets

The global guard, @Public(), @CurrentUser() and AuthenticationContext work in GraphQL resolvers and WebSocket gateways too. In GraphQL the guard reads the cookie and headers from the req on the GraphQL context, so the context option must be a function that builds a context per request. Nest runs global guards and interceptors on root resolvers only: add fieldResolverEnhancers: ['guards', 'interceptors'] to guard field resolvers too. Nest also runs no guards on a gateway’s handleConnection(), so authenticate the handshake with WsAuthenticator.

A resolver, a gateway and the GraphQL module
import { Module } from '@nestjs/common';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { Field, GraphQLModule, ObjectType, Query, Resolver } from '@nestjs/graphql';
import { SubscribeMessage, WebSocketGateway, type OnGatewayConnection } from '@nestjs/websockets';
import { Authenticate, AuthenticationContext, CurrentUser, Public, WsAuthenticator } from '@nestjs/authentication';
import type { Socket } from 'socket.io';
import { AuthenticationDemoModule } from './authentication-demo.module.js';
import type { User } from './user.js';

@ObjectType()
export class Profile {
  @Field() id: string;
  @Field() email: string;
}

@Resolver(() => Profile)
export class ProfileResolver {
  constructor(private readonly context: AuthenticationContext) {}

  // The global guard protects resolvers too; @CurrentUser() reads the user from the GraphQL context.
  @Query(() => Profile)
  whoAmI(@CurrentUser() user: User) {
    return user;
  }

  @Public()
  @Query(() => String)
  ping() {
    return 'pong';
  }

  @Authenticate({ optional: true })
  @Query(() => String)
  viaContext() {
    return this.context.user ? 'signed-in' : 'guest';
  }
}

@WebSocketGateway()
export class ChatGateway implements OnGatewayConnection {
  constructor(private readonly wsAuthenticator: WsAuthenticator) {}

  // Nest runs no guards on handleConnection(): authenticate the handshake explicitly.
  async handleConnection(client: Socket) {
    await this.wsAuthenticator.authenticateConnection(client);
  }

  // The global guard authenticates every message too.
  @SubscribeMessage('whoami')
  whoami(@CurrentUser('email') email: string) {
    return { email };
  }
}

@Module({
  imports: [
    AuthenticationDemoModule,
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      subscriptions: { 'graphql-ws': true },
      // Over graphql-ws there is no request per operation: the upgrade request stands in for it.
      context: ({ req, res, extra }: { req?: any; res?: any; extra?: any }) => ({ req: req ?? extra?.request, res }),
      fieldResolverEnhancers: ['guards', 'interceptors'],
    }),
  ],
  providers: [ProfileResolver, ChatGateway],
})
export class RealtimeModule {}

An anonymous GraphQL query answers 200 with errors[0].extensions.code equal to UNAUTHENTICATED; with the cookie whoAmI returns the user; @Public() and @Authenticate({ optional: true }) behave as on HTTP. An anonymous socket.io client is disconnected by the server (io server disconnect), a client with the session cookie in its handshake headers gets { "email": "ws@example.com" } back from the whoami message. authenticateConnection() closes an unauthenticated connection (code 1008 with ws, disconnect() with socket.io); pass { required: false } as the third argument to keep guests. See GraphQL: Code-First & Schema-First and WebSockets.

A pitfall found while running this: a plain ThrottlerGuard registered globally crashes on a GraphQL operation (Cannot read properties of undefined (reading 'ip')), because it looks for the request on the HTTP execution context. The getRequestResponse() override in the guard above fixes it; the throttler documentation shows the same override for GraphQL.

Testing

End-to-end tests boot the real module with two things changed: the PasswordHasher is replaced by a cheap instance (scrypt costs a few hundred milliseconds and 128 MiB per hash at the default settings), and email goes to something the test can read. The official documentation runs the tests on PGlite with the real migrations and reads mail with InMemoryMailTransport from @nestjs/mail; this version uses the in-memory stand-ins of this page, which are the same shape without a database.

test/authentication.e2e-spec.ts (Vitest and supertest)
import { randomBytes } from 'node:crypto';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PasswordHasher } from '@nestjs/authentication';
import request from 'supertest';
import { AuthenticationDemoModule } from '../src/authentication/authentication-demo.module.js';
import { LinkOutbox } from '../src/authentication/link-outbox.js';

describe('Authentication (e2e)', () => {
  let app: INestApplication;
  let outbox: LinkOutbox;
  // Generated per run: no credential-like literal in the repository.
  const ada = { email: 'ada@example.com', password: randomBytes(12).toString('hex') };

  beforeAll(async () => {
    // In an application these come from the environment through ConfigService.
    process.env.JWT_SECRET = randomBytes(32).toString('hex');
    process.env.TOTP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
    process.env.APP_URL = 'http://localhost:3000';
    process.env.GOOGLE_CLIENT_ID = 'test-client.apps.example';
    process.env.GOOGLE_CLIENT_SECRET = randomBytes(16).toString('hex');

    const moduleRef = await Test.createTestingModule({ imports: [AuthenticationDemoModule] })
      .overrideProvider(PasswordHasher)
      .useValue(new PasswordHasher({ logN: 10 })) // scrypt is slow on purpose: use a cheap instance in tests
      .compile();
    app = moduleRef.createNestApplication();
    await app.init();
    outbox = app.get(LinkOutbox);
  });

  afterAll(() => app.close());

  it('signs up, verifies the address, then reads the profile with the session cookie', async () => {
    const res = await request(app.getHttpServer()).post('/auth/sign-up').send(ada).expect(201);
    const cookie = (res.headers['set-cookie'] as unknown as string[])[0].split(';')[0];

    // The link the user would have received by email.
    const token = new URL(outbox.last('verify', ada.email)!.url).searchParams.get('token');
    await request(app.getHttpServer()).post('/auth/email/verify').send({ token }).expect(200);

    await request(app.getHttpServer())
      .get('/me')
      .set('Cookie', cookie)
      .expect(200, { id: res.body.id, email: ada.email, emailVerified: true, roles: ['reader'] });
  });

  it('issues bearer tokens to the mobile app', async () => {
    const res = await request(app.getHttpServer()).post('/auth/token').send(ada).expect(200);

    await request(app.getHttpServer())
      .get('/me/orders')
      .set('Authorization', `Bearer ${res.body.accessToken}`)
      .expect(200, []);
  });

  it('rejects anonymous requests and wrong passwords', async () => {
    await request(app.getHttpServer()).get('/me').expect(401);
    await request(app.getHttpServer())
      .post('/auth/sign-in')
      .send({ ...ada, password: randomBytes(12).toString('hex') }) // any other password
      .expect(401);
  });
});

To sign a test user in without a sign-in route, SessionService.create() returns a session cookie and TokenService.issue() a bearer token (for a user without an authenticator). To test code that reads AuthenticationContext without HTTP, call run() with an authentication result and a function. The package also exports authenticationStoreContract() from @nestjs/authentication/testing, a suite of cases for any store you write. See Testing.

Stores, events, errors and production

Everything the package keeps (sessions, refresh tokens, authenticators and recovery codes, pending links and logins) lives in memory until you register a store. The package does not talk to your database: it describes what it stores as six interfaces, SessionStore, RefreshTokenStore, MfaStore, MagicLinkStore, OidcStateStore and EmailTokenStore. A store is an ordinary singleton provider that registers itself with registerSource() on AuthenticationStorage, by contract name. The documentation implements it with Drizzle (and TypeORM) and describes the rules for Prisma. Whatever decides that something works only once (spending a refresh token, consuming a link, claiming a TOTP step) must be one conditional statement, never a read followed by a write. With NODE_ENV=production startup fails while a contract that a configured feature uses has no store.

AuthenticationEvents publishes every sign-in and sign-out, second-factor change, reused refresh token, password reset and email verification on its events$ observable and on node:diagnostics_channel (channels named nestjs:authentication:<type>). Alert on repeated mfa-failed events with locked: true, and on many password-reset-requested events. The errors are subclasses of AuthenticationError and become the transport’s native error (an HTTP exception, GraphQL UNAUTHENTICATED, a WsException or an RpcException):

Error Status error in the body When

AuthenticationError

401

none

Missing or bad credentials, or requireUser() without a user.

JwtError

401

none

A token is malformed, badly signed, expired, or for another issuer or audience.

RefreshTokenError

401

none

A refresh token is invalid, expired or reused (reason says which).

MagicLinkError

401

not_this_browser

A magic link was opened in another browser than the one that requested it.

MfaAlreadyEnrolledError

409

Conflict

enroll() without replace: true for a user with a confirmed authenticator.

The documentation’s production checklist, condensed: register stores for everything the module keeps; send mail through a real transport (a logging transport writes every link to the log, where anyone who reads the logs could sign in as the user); serve over HTTPS because the session cookie is Secure; call app.enableCsrfProtection(); keep the JWT secret, TOTP keys and Google client secret in a secret manager; build the verification and reset pages to POST the token; size the servers for scrypt (128 MiB per hash on a thread pool of four threads unless UV_THREADPOOL_SIZE is set); use shared storage for the throttler; rotate sessions on privilege changes and end other sessions after a password change; treat API keys as secrets and add their prefix to your secret scanner; and import AuthenticationModule before AuthorizationModule.

The established approach: Passport and JWT

@nestjs/passport wraps Passport, the most popular Node.js authentication library, in Nest constructs. A Passport strategy has options and a verify callback; in Nest you extend PassportStrategy(Strategy), pass the options to super() and implement validate(). Whatever validate() returns becomes request.user; throwing, or returning a falsy value, answers 401. The flow on this page is the standard one: a local strategy checks the email and password at POST /auth/login, the route returns a JWT signed with @nestjs/jwt, and a jwt strategy verifies the bearer token on every other route. It is documented as a recipe, and it is what the book and most applications written before the 2026 restructure use.

npm install --save @nestjs/passport passport passport-local passport-jwt @nestjs/jwt argon2
npm install --save-dev @types/passport-local @types/passport-jwt
openssl rand -hex 32   # JWT_SECRET; run again for JWT_REFRESH_SECRET

For any strategy you need @nestjs/passport and passport plus the strategy package. The examples below use @nestjs/passport 12.0.0, passport 0.7.0, passport-local 1.0.0, passport-jwt 4.0.1, @nestjs/jwt 12.0.2 and argon2 0.45.1.

An in-memory user store that is hashed from the environment at startup (no credential in the source)
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PasswordService } from './password.service.js';

export interface User {
  id: number;
  email: string;
  passwordHash: string;
}

@Injectable()
export class UsersService implements OnModuleInit {
  private readonly users: User[] = [];

  constructor(
    private readonly config: ConfigService,
    private readonly passwords: PasswordService,
  ) {}

  // Stand-in for a database: one account seeded from the environment, stored hashed.
  async onModuleInit() {
    const email = this.config.get<string>('SEED_EMAIL');
    const password = this.config.get<string>('SEED_PASSWORD');
    if (email && password) {
      this.users.push({ id: 1, email, passwordHash: await this.passwords.hash(password) });
    }
  }

  async findByEmail(email: string): Promise<User | undefined> {
    return this.users.find((user) => user.email === email);
  }

  async findById(id: number): Promise<User | undefined> {
    return this.users.find((user) => user.id === id);
  }
}
The local strategy: passport-local reads "username" by default, so name the field
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service.js';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({ usernameField: 'email' }); // passport-local defaults to "username"
  }

  // Passport calls validate() with the credentials; the return value becomes request.user.
  async validate(email: string, password: string) {
    const user = await this.authService.validateUser(email, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
The JWT strategy: secret from ConfigService, algorithm pinned, expiry checked
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { AccessPayload } from './auth.service.js';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
      algorithms: ['HS256'], // pin the algorithm instead of trusting the token header
    });
  }

  // Runs only after the signature and expiry are valid.
  validate(payload: AccessPayload) {
    return { userId: payload.sub, email: payload.email };
  }
}

ignoreExpiration: false makes Passport reject an expired token without further code. algorithms: ['HS256'] pins the algorithm instead of trusting the token header; a token signed with another secret and an alg: none token both answer 401.

The auth service: signAsync issues tokens, verifyAsync checks refresh tokens
import { randomUUID } from 'node:crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PasswordService } from './password.service.js';
import { type User, UsersService } from './users.service.js';

export interface AccessPayload {
  sub: number;
  email: string;
}

interface RefreshPayload {
  sub: number;
  jti: string;
}

// A password that never matches, so an unknown email costs the same time as a wrong password.
const DUMMY_HASH_SOURCE = randomUUID();

@Injectable()
export class AuthService {
  private dummyHash?: Promise<string>;
  // Stand-in for a table: the jti of the one refresh token that is currently valid per user.
  private readonly currentRefresh = new Map<number, string>();

  constructor(
    private readonly users: UsersService,
    private readonly passwords: PasswordService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
  ) {}

  /** Called by the local strategy: the user, or null. */
  async validateUser(email: string, password: string): Promise<Omit<User, 'passwordHash'> | null> {
    const user = await this.users.findByEmail(email);
    this.dummyHash ??= this.passwords.hash(DUMMY_HASH_SOURCE);
    const valid = await this.passwords.verify(user?.passwordHash ?? (await this.dummyHash), password);
    if (!user || !valid) {
      return null;
    }
    const { passwordHash: _omit, ...safe } = user;
    return safe;
  }

  async login(user: { id: number; email: string }) {
    const payload: AccessPayload = { sub: user.id, email: user.email };
    return {
      access_token: await this.jwt.signAsync(payload), // secret and expiresIn come from JwtModule
      refresh_token: await this.signRefresh(user.id),
    };
  }

  // Refresh-token rotation: each refresh token works once; a replay gets a 401.
  async refresh(token: string) {
    let payload: RefreshPayload;
    try {
      payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
        secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
      });
    } catch {
      throw new UnauthorizedException();
    }
    if (this.currentRefresh.get(payload.sub) !== payload.jti) {
      this.currentRefresh.delete(payload.sub); // reuse detected: end the chain
      throw new UnauthorizedException();
    }
    const user = await this.users.findById(payload.sub);
    if (!user) {
      throw new UnauthorizedException();
    }
    return this.login(user);
  }

  private async signRefresh(userId: number) {
    const jti = randomUUID();
    this.currentRefresh.set(userId, jti);
    return this.jwt.signAsync(
      { sub: userId, jti },
      { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '7d' },
    );
  }
}

The Passport recipe stops at a single access token and says that expiration and refresh "are beyond the scope of this article". The refresh() method above is the application’s own pattern, not an API of the official page: the refresh token is a second JWT signed with a different secret (so an access token is never accepted as a refresh token), carries a jti, and the service remembers the only jti that is currently valid per user. A refresh therefore works once, a replay answers 401 and ends the chain. In production keep the jti in a database or Redis, hashed, and revoke on password change. JwtService.signAsync() and verifyAsync() are the promise-returning methods; the synchronous sign() and verify() also exist, but the asynchronous ones are what the documentation uses. JwtModule carries the default secret and signOptions, and per-call options (as for the refresh token) override them.

Guards: AuthGuard('local') for login, a global AuthGuard('jwt') with an opt-out
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public.decorator.js';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

// Registered globally (APP_GUARD): every route needs a valid JWT unless it is @Public().
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
    return isPublic ? true : super.canActivate(context);
  }
}
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

AuthGuard('jwt') is the guard that the jwt strategy produces. Registering a subclass as APP_GUARD makes every endpoint protected, and @Public() (a SetMetadata() decorator read with Reflector.getAllAndOverride(), see Guards and Custom Decorators) marks the exceptions. Overriding handleRequest(err, user, info) of an AuthGuard subclass lets you throw your own exception, and AuthGuard(['a', 'b']) chains strategies: the first to succeed wins.

Controller and module
import { Body, Controller, Get, HttpCode, Post, Request, UseGuards } from '@nestjs/common';
import type { Request as ExpressRequest } from 'express';
import { AuthService } from './auth.service.js';
import { LocalAuthGuard } from './guards.js';
import { Public } from './public.decorator.js';

@Controller()
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public() // the global guard would otherwise demand a JWT to obtain a JWT
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  @HttpCode(200)
  login(@Request() req: ExpressRequest) {
    return this.authService.login(req.user as { id: number; email: string });
  }

  @Public()
  @Post('auth/refresh')
  @HttpCode(200)
  refresh(@Body('refresh_token') refreshToken: string) {
    return this.authService.refresh(refreshToken);
  }

  @Get('profile')
  profile(@Request() req: ExpressRequest) {
    return req.user;
  }

  @Public()
  @Get('health')
  health() {
    return { status: 'ok' };
  }
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
import { JwtAuthGuard } from './guards.js';
import { JwtStrategy } from './jwt.strategy.js';
import { LocalStrategy } from './local.strategy.js';
import { PasswordService } from './password.service.js';
import { UsersService } from './users.service.js';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.getOrThrow<string>('JWT_SECRET'), // openssl rand -hex 32
        signOptions: { expiresIn: '15m', algorithm: 'HS256' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    UsersService,
    PasswordService,
    LocalStrategy,
    JwtStrategy,
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
})
export class PassportDemoModule {}
curl -X POST http://localhost:3000/auth/login -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"<the seeded password>"}'
curl http://localhost:3000/profile -H "Authorization: Bearer $ACCESS_TOKEN"
{ "access_token": "<jwt>", "refresh_token": "<jwt>" }
{ "userId": 1, "email": "ada@example.com" }

Without a token, GET /profile answers { "message": "Unauthorized", "statusCode": 401 }, exactly like the other approach.

Other details from the recipe worth knowing:

  • LocalAuthGuard is @UseGuards() on the login route; because the global JWT guard would otherwise demand a JWT to obtain a JWT, the route is also @Public().

  • Strategies are registered with the global Passport instance, so they cannot have per-request options. To use a request-scoped provider inside one, set passReqToCallback: true and resolve it with ModuleRef.resolve() and ContextIdFactory.getByRequest() (see ModuleRef, Circular Dependencies & Lazy Loading).

  • PassportModule.register({ session: true }) passes options to Passport’s authenticate(); strategies can be named with a second argument to PassportStrategy() and used as AuthGuard('name').

  • For GraphQL, extend AuthGuard and override getRequest() to return the request from GqlExecutionContext, and write a @CurrentUser() decorator that reads req.user from that context. For passport-local the GraphQL arguments must also be copied into the request body. See NestJS: Passport - GraphQL.

  • Express and Fastify: on both adapters POST /auth/login answers 200 with the token pair. The type Request imported from express in the controller is only a TypeScript annotation, so on Fastify use a minimal interface (or FastifyRequest) instead.

Password hashing: scrypt, argon2 and bcrypt

Never store a password, and never use a fast hash such as SHA-256 for one. Use a slow, salted, adaptive function and store the self-describing hash string.

  • scrypt (PasswordHasher of @nestjs/authentication, and crypto.scrypt in Node). Memory-hard (which bcrypt is not), runs on the libuv thread pool and needs no native dependency. PasswordHasher normalizes passwords to NFKC, refuses an empty password or one above 4 KiB with a RangeError, and provides needsRehash(). Outside the module create one with new PasswordHasher().

  • argon2 (the argon2 package, argon2id). The current OWASP first choice. It is a native module. The hash string records its parameters ($argon2id$v=19$m=65536,p=4,t=3$…​ with the defaults of argon2 0.45.1), and needsRehash() tells you to upgrade a stored hash after a successful login.

  • bcrypt (the bcrypt package, 6.0.0 here). Still widespread, also native, and it reads only the first 72 bytes of the password; hash a pre-digest or enforce a maximum length. With a cost of 12 the hash starts with $2b$12$.

argon2id behind a provider
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';

@Injectable()
export class PasswordService {
  // argon2id with the library defaults; the hash string records its own parameters and salt.
  hash(password: string): Promise<string> {
    return argon2.hash(password, { type: argon2.argon2id });
  }

  verify(hash: string, password: string): Promise<boolean> {
    return argon2.verify(hash, password);
  }

  // True when the stored hash was made with weaker parameters than the current defaults.
  needsRehash(hash: string): boolean {
    return argon2.needsRehash(hash);
  }
}
The same contract with bcrypt
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

// The same contract with bcrypt. Only the first 72 bytes of a password are used.
@Injectable()
export class BcryptPasswordService {
  hash(password: string): Promise<string> {
    return bcrypt.hash(password, 12);
  }

  verify(hash: string, password: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}

The official page says the argon2 and bcrypt packages work with Nest as they are. When a request has no matching account, still verify against a dummy hash, as validateUser() does above, so the response time does not reveal which emails exist. Details and the scrypt/aes-256-gcm examples are in Rate Limiting & Encryption.

Comparison

Concern @nestjs/authentication (pre-1.0) @nestjs/passport + @nestjs/jwt (established)

Status

First-party, 0.0.x, taught in the main documentation since September 2026. API may change before 1.0.

Mature, stable, huge strategy ecosystem; documented as a recipe.

Protecting routes

Global guard registered by the module; @Public(), @Authenticate().

You register an AuthGuard('jwt') subclass as APP_GUARD and write @Public() yourself.

Browser sessions

Built in: __Host-sid cookie, hashed token, idle and absolute timeouts, session list and revocation.

Not provided; use express-session or @fastify/secure-session yourself.

Mobile tokens and refresh

Built in: access JWT, opaque rotating refresh tokens in families, reuse detection, revocation.

Access token with @nestjs/jwt; refresh and revocation are yours to write (the pattern above).

Two-factor, magic links, email flows

Built in (TOTP with encrypted secrets, recovery codes, step-up with @Authenticate({ mfa: true })).

Third-party strategies and your own code.

Social login

OidcService with google(), github(), microsoft() presets and an account resolver.

Any of hundreds of Passport strategies (passport-google-oauth20, SAML, LDAP, …​).

API keys

ApiKeyProvider with hashing and expiry.

Write a strategy (for example passport-headerapikey) and the storage.

Password hashing

PasswordHasher (scrypt) built in.

Bring argon2 or bcrypt.

State and stores

Keeps state through six store interfaces that you implement for production.

Stateless JWT; any refresh state is yours.

Transports

Same guard for HTTP, GraphQL, WebSockets (WsAuthenticator) and microservices; errors translated per transport.

HTTP out of the box; GraphQL needs a custom guard that overrides getRequest().

Platforms

Cookies and redirects through Nest’s HTTP adapter: Express and Fastify without @Req() or @Res().

Passport works on both adapters (login behaves the same on Express and Fastify), but typical code touches req.user of the underlying request.

Dependencies

Node.js built-ins only.

passport, strategy packages, @nestjs/jwt and a hashing library.

Choose @nestjs/authentication for a new application that wants sessions, mobile tokens and 2FA with one coherent, secure-by-default design, and accept the pre-1.0 risk by pinning the version. Choose Passport when you need a strategy it does not ship (SAML, LDAP, a specific identity provider), when you already run it, or when you only need to verify a JWT issued by an identity provider. For an external identity provider you can often skip both and verify the provider’s tokens with a JWKS-based JWT provider (see OpenID Connect).

Express and Fastify

  • @nestjs/authentication sets cookies and performs redirects through Nest’s HTTP adapter. The sign-up, /me and sign-out sequence behaves identically on Express and on Fastify, and its controllers never touch @Req() or @Res().

  • Passport’s login works on both adapters. Express’s Request type in a controller signature is a compile-time annotation only.

  • Cookie parsing is built into Nest 12.1, so the session cookie needs no cookie-parser or @fastify/cookie. Those are needed only if other code of yours reads cookies with their APIs (see Cookies & Sessions).

  • In tests on Fastify, call app.init() followed by app.getHttpAdapter().getInstance().ready() before sending requests with supertest; otherwise the first requests can reach a server that is not ready yet.

Pitfalls

  • Secrets in source. JWT_SECRET, TOTP_ENCRYPTION_KEY and client secrets come from ConfigService (generate them with openssl rand -hex 32). getOrThrow() fails fast; a missing accessToken.key stops the package at startup.

  • Using the access-token secret for refresh tokens. Use a separate secret, or an opaque refresh token; otherwise an access token is also a valid refresh token.

  • Trusting the JWT header. Pin algorithms in passport-jwt (and alg in the package options); never accept none.

  • Revealing which accounts exist. Verify against a dummy hash for unknown users, answer 202 to reset requests without waiting, use one error message for "unknown email" and "wrong password".

  • Links that GET. A verification or magic link that consumes the token on GET is spent by email scanners. Make the page POST it.

  • No rate limiting on sign-in. Throttle per (email, IP) and add a second limiter keyed on the email alone for distributed guessing.

  • Roles in tokens. Roles baked into a JWT outlive a role change. Load the current user in validate().

  • Long-lived access tokens. They cannot be revoked; keep them to minutes and revoke refresh tokens.

  • In-memory state in production. Sessions, refresh tokens and throttler counters in memory vanish on restart and are not shared between instances.

  • Global guards that run in the wrong order. Import authentication before authorization, and put the rate limiter before both.

  • bcrypt and long passwords. Only 72 bytes count.

  • ThrottlerGuard on GraphQL. Override getRequestResponse() (shown above).

Where the book is outdated

The reference book for this section builds sign-up and login by hand: bcrypt hashing, a JWT created with @nestjs/jwt and a custom guard, without Passport. That still works and its building blocks (JwtService, a guard, Reflector) are what the Passport approach above is made of, but the picture around it has changed. The official documentation now teaches @nestjs/authentication first, and moved the Passport and JWT approach to a recipe. A hand-written JWT guard rarely handles expiry, refresh-token rotation, reuse detection, revocation, second factors, cookie security (HttpOnly, Secure, __Host-, SameSite, login CSRF) or account-linking pre-hijacking, all of which the package or this page’s examples cover. Current bcrypt is 6.0.0 and still reads only 72 bytes, and password hashes should be upgraded on login when the cost settings change. Node.js 16 and 18, and TypeScript 4 patterns such as import * as helmet, are gone; the examples here are ESM, strict TypeScript 6, with .js extensions on relative imports.

What changed recently

  • September 2026: the documentation was restructured; authentication moved to /security/authentication and is now the tutorial for @nestjs/authentication, a new first-party package published as a 0.0.x pre-release. The Passport and JWT material moved to /recipes/passport.

  • NestJS 12.1 (September 2026) added built-in adapter-agnostic cookies, app.enableCsrfProtection() and app.useSecurityHeaders(), which the package’s CSRF decisions and the production checklist rely on.

  • NestJS 12 requires Node.js 20.19+ or 22.12+ and publishes ESM-only core packages; @nestjs/passport, @nestjs/jwt, @nestjs/throttler and the other companion packages moved to the 12 line (12.0.x) or stayed on their own majors (throttler 6.7).

  • Nest 12.1 validates any Standard Schema object, so DTOs can be Zod, Valibot or ArkType schemas as well as class-validator classes.