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
HttpOnlycookie 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.
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 a401, not a400(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
AppModuleruns before the one a library module registers.@nestjs/authenticationregisters 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).
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[];
}
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.
@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 |
|---|---|---|
|
|
Register the module and |
|
|
Start with |
|
|
The session cookie ( |
|
|
Maximum session lifetime however active, and the idle timeout ( |
|
|
How often activity is written to the store at most. Must be shorter than |
|
|
Cookie attributes ( |
|
none |
Other origins whose pages may use the session cookie for writes and sign in. |
|
none |
A function of the sign-in request whose result is stored with each new session. |
|
|
Base-2 logarithm of the scrypt cost N (10 to 22). |
|
required |
HS256 secret of at least 32 bytes, or a private key for RS256, ES256 or EdDSA (PEM or |
|
none, none, |
Written into and checked on every token. |
|
|
One refresh token, and a whole refresh-token family. |
|
required with |
|
|
|
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. |
|
|
The web-app page the link opens. |
|
|
Presets |
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.
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.
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.
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;
}
}
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).
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 treathttp://localhostas a secure origin, so development needs no HTTPS. On another host over plain HTTP setcookie.securetofalse(the cookie is then namedsid). -
Besides
SameSite=Lax, the module treatsPOST,PUT,PATCHandDELETErequests and WebSocket handshakes that come from another origin (judged bySec-Fetch-Site,OriginandHost) as anonymous. Clients that send neither header, such as curl and mobile apps, are unaffected. List a web app on another origin insession.trustedOrigins, and on Nest 12.1 or later also callapp.enableCsrfProtection()(see Security Headers, CORS & CSRF). For plain cookies andexpress-sessionsee 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 }).
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);
}
}
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.
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()returnsnullfor an unknown, used or expired token and the route answers400. 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 same202in 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 thepassword-resetevent. -
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_txcookie andconsume()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.
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 |
|---|---|
|
Let anonymous callers through with |
|
Require a verified second factor: a verified session, or a JWT whose |
|
Require a verified address, or answer |
|
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.
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.
-
claimsare stored with the refresh-token family and copied into every access token it mints. Theamrclaim records how the user signed in, and two-factor authentication relies on it. Keep facts that can change, such as roles, out ofclaims: 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.
-
JwtAuthleaves keys toApiKeyAuth: 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.
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.
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()returnssecretand anotpauth://urifor a QR code.confirmactivates 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 answer401witherror: mfa_required, which tells the web app to ask for a code. That is whyverifyis@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 withmfa: '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 answers401withmfa_required; the app sends the password again withsecondFactor.
{ "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.
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.
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;
}
}
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.
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.
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.
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);
});
});
Source: NestJS: Authentication - Testing
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 |
|---|---|---|---|
|
401 |
none |
Missing or bad credentials, or |
|
401 |
none |
A token is malformed, badly signed, expired, or for another issuer or audience. |
|
401 |
none |
A refresh token is invalid, expired or reused ( |
|
401 |
|
A magic link was opened in another browser than the one that requested it. |
|
409 |
|
|
Source: NestJS: Authentication - Errors
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.
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);
}
}
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;
}
}
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.
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.
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.
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 {}
Source: NestJS: Passport - Login route
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:
-
LocalAuthGuardis@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: trueand resolve it withModuleRef.resolve()andContextIdFactory.getByRequest()(see ModuleRef, Circular Dependencies & Lazy Loading). -
PassportModule.register({ session: true })passes options to Passport’sauthenticate(); strategies can be named with a second argument toPassportStrategy()and used asAuthGuard('name'). -
For GraphQL, extend
AuthGuardand overridegetRequest()to return the request fromGqlExecutionContext, and write a@CurrentUser()decorator that readsreq.userfrom that context. Forpassport-localthe GraphQL arguments must also be copied into the request body. See NestJS: Passport - GraphQL. -
Express and Fastify: on both adapters
POST /auth/loginanswers200with the token pair. The typeRequestimported fromexpressin the controller is only a TypeScript annotation, so on Fastify use a minimal interface (orFastifyRequest) 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 (
PasswordHasherof@nestjs/authentication, andcrypto.scryptin Node). Memory-hard (which bcrypt is not), runs on the libuv thread pool and needs no native dependency.PasswordHashernormalizes passwords to NFKC, refuses an empty password or one above 4 KiB with aRangeError, and providesneedsRehash(). Outside the module create one withnew PasswordHasher(). -
argon2 (the
argon2package,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 ofargon20.45.1), andneedsRehash()tells you to upgrade a stored hash after a successful login. -
bcrypt (the
bcryptpackage, 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$.
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);
}
}
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, |
Mature, stable, huge strategy ecosystem; documented as a recipe. |
Protecting routes |
Global guard registered by the module; |
You register an |
Browser sessions |
Built in: |
Not provided; use |
Mobile tokens and refresh |
Built in: access JWT, opaque rotating refresh tokens in families, reuse detection, revocation. |
Access token with |
Two-factor, magic links, email flows |
Built in (TOTP with encrypted secrets, recovery codes, step-up with |
Third-party strategies and your own code. |
Social login |
|
Any of hundreds of Passport strategies ( |
API keys |
|
Write a strategy (for example |
Password hashing |
|
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 ( |
HTTP out of the box; GraphQL needs a custom guard that overrides |
Platforms |
Cookies and redirects through Nest’s HTTP adapter: Express and Fastify without |
Passport works on both adapters (login behaves the same on Express and Fastify), but typical code touches |
Dependencies |
Node.js built-ins only. |
|
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/authenticationsets cookies and performs redirects through Nest’s HTTP adapter. The sign-up,/meand 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
Requesttype 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-parseror@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 byapp.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_KEYand client secrets come fromConfigService(generate them withopenssl rand -hex 32).getOrThrow()fails fast; a missingaccessToken.keystops 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
algorithmsinpassport-jwt(andalgin the package options); never acceptnone. -
Revealing which accounts exist. Verify against a dummy hash for unknown users, answer
202to 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
GETis 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.
-
ThrottlerGuardon GraphQL. OverridegetRequestResponse()(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/authenticationand is now the tutorial for@nestjs/authentication, a new first-party package published as a0.0.xpre-release. The Passport and JWT material moved to/recipes/passport. -
NestJS 12.1 (September 2026) added built-in adapter-agnostic cookies,
app.enableCsrfProtection()andapp.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/throttlerand 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-validatorclasses.
Related pages
-
Guards:
CanActivate,APP_GUARD,Reflector,@Public()in depth. -
Authorization: roles and policies after the user is known.
-
Cookies & Sessions: the cookie API underneath.
-
OAuth 2.0 and OpenID Connect for the protocol theory.