_
Production-Ready Authentication in NestJS
This is a follow-up to my previous article on JWT authentication in Node.js, which covered the fundamentals — what a JWT is, why passwords need hashing, how a middleware verifies a token. I will not repeat that here.
A production Node.js backend today, with a fairly high probability, is NestJS, TypeScript, PostgreSQL, Prisma, and Redis. This article assumes that stack is already in place — the Nest project exists, Postgres is running, and Prisma is connected. Scaffolding a new project is out of scope. What this is, instead, is a guide to building an auth module the way it actually looks in a real backend: registration, login, protected routes, refresh tokens, and a caching layer, wired together with Passport instead of a hand-rolled middleware.
A Quick Recap: JWT and Passport
A JWT (JSON Web Token) is a signed string the server hands out after a successful login. It carries a payload — usually just enough to identify the user — and a signature. The signature is what matters: it lets the server detect if the token was changed. Anyone can read the payload, so it should never contain a password or anything sensitive — only the server's secret can produce a valid signature.
Passport is a Node.js authentication library built around one idea: a strategy. A strategy is a self-contained module that knows how to do one specific kind of authentication — a local strategy checks a username and password, a jwt strategy verifies a bearer token, a google strategy handles an OAuth redirect. You register the strategies you need, and Passport handles running them against incoming requests.
NestJS does not reinvent this. @nestjs/passport wraps Passport so that a strategy becomes an injectable provider (it can use ConfigService, UserService, anything from the DI container), and running that strategy against a request becomes a @UseGuards() call instead of manually wiring middleware. That is really all "auth in NestJS" is: a Passport strategy, plus a guard that runs it.
Step 1: How the Access/Refresh Token Flow Works
A single JWT is stateless — once signed, the server has no way to reach out and revoke it before it expires. That is fine for a token that only lives a few minutes, but a token that has to survive for days, so the user is not forced to log in constantly, is a very different risk if it ever leaks. The standard fix is not one token, but two, each with a different lifetime and a different job.
Access token. Short-lived — minutes, not days. It is sent with every request and checked on every request; Step 5 shows exactly how. If it leaks, the exposure window is only as long as its expiration time.
Refresh token. Long-lived — days or weeks. It does exactly one thing: it gets you a new access token. By default a cookie goes out with every request, but this one's cookie is scoped to only go to /auth/refresh (Step 7 shows how). It gets sent much less, so there is much less chance of it leaking.
Here is how the two work together:
Login
-> server issues an access token (short-lived) and a refresh token (long-lived)
Every request
-> client sends the access token
-> server verifies the signature and expiration, then resolves the user (Step 11 makes this cheap)
Access token expires
-> client calls /auth/refresh with the refresh token
-> server verifies it, issues a brand new access/refresh pair
-> client keeps working, no login prompt
Step 2: The User Model
First, a Prisma model for the user, and a module that exposes a UserService.
model User {
id String @id @default(uuid())
name String
email String @unique
password String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
import { Module } from "@nestjs/common";
import { UserService } from "./user.service";
@Module({
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
UserService itself is a thin wrapper around Prisma:
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "@/infra/prisma/prisma.service";
@Injectable()
export class UserService {
constructor(private readonly prismaService: PrismaService) {}
public async findByEmail(email: string) {
return this.prismaService.user.findUnique({
where: { email },
select: { ...userSelect, password: true },
});
}
public async findById(id: string) {
const user = await this.prismaService.user.findUnique({
where: { id },
select: userSelect,
});
if (!user) {
throw new NotFoundException("User not found");
}
return user;
}
}
const userSelect = {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
};
findByEmail pulls in the password on purpose — Step 7 needs it to check a login attempt. findById never does; it is for anything that just needs to know who a user is, not to authenticate one.
Step 3: Installing Dependencies
npm install @nestjs/passport @nestjs/jwt passport passport-jwt argon2 ms
npm install -D @types/passport-jwt @types/ms
And the environment variables the auth module will read from config:
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRATION_TIME=15m
JWT_REFRESH_EXPIRATION_TIME=7d
COOKIE_DOMAIN=localhost
Two expiration times, not one — that is the access/refresh split from Step 1, and Step 6 is where they actually get used.
Step 4: The Auth Module
The module ties everything together: PassportModule registers jwt as the default strategy, and JwtModule configures how tokens get signed, using the secret from config rather than a hardcoded string.
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { PassportModule } from "@nestjs/passport";
import { JwtModule } from "@nestjs/jwt";
import { UserModule } from "@/api/user/user.module";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./strategies/jwt.strategy";
@Module({
imports: [
UserModule,
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>("JWT_SECRET"),
signOptions: { algorithm: "HS256" },
ignoreExpiration: false,
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
AuthModule imports UserModule to get UserService — that is a one-way dependency, not a cycle, so no forwardRef is needed here. @Authorization() and AuthGuard('jwt') from Step 8 do not require the reverse import either: a Passport strategy registers itself globally the moment Nest instantiates it, regardless of which module declared it.
Step 5: The JWT Strategy
This is the piece from the "quick recap" above, made concrete. A strategy class extends PassportStrategy, tells Passport where to find the token, and implements one method: validate.
The payload signed into both tokens is minimal — just enough to identify the user and which of the two tokens this is:
export interface JWTAccessTokenPayload {
userId: string;
tokenType: "access" | "refresh";
}
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Request } from "express";
import { UserService } from "@/api/user/user.service";
import { JWTAccessTokenPayload } from "@/api/auth/auth.interfaces";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
public constructor(
private readonly configService: ConfigService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => {
const cookies = req.cookies as Record<string, string | undefined> | undefined;
return cookies?.accessToken ?? null;
},
]),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
});
}
public async validate(payload: JWTAccessTokenPayload) {
if (payload.tokenType !== "access") {
throw new UnauthorizedException("Invalid token type");
}
return this.userService.findByIdForAuth(payload.userId);
}
}
Two things worth calling out. First, jwtFromRequest — the standard passport-jwt setup reads the token from an Authorization: Bearer ... header, using ExtractJwt.fromAuthHeaderAsBearerToken(). Here it is replaced with a custom extractor that reads a cookie instead, for reasons Step 7 covers.
Second, validate only runs after Passport has already checked the signature and expiration itself — you never verify the token by hand. Whatever validate returns becomes req.user for the rest of the request. Returning nothing, or throwing, is treated as "unauthenticated" by the guard that calls this strategy.
findByIdForAuth is not one of the methods shown in Step 2 — it is a third one, added specifically for this lookup, and Step 11 is where it actually appears.
Step 6: Registration — Why Argon2, Not Bcrypt
The previous article used bcrypt. Both bcrypt and argon2 are established, battle-tested standards, and either one is a fine choice. argon2 is the newer of the two, and it is generally considered slightly stronger, so for a new project I would reach for that instead.
The registration payload is validated with class-validator decorators on the DTO:
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from "class-validator";
export class CreateUserDto {
@ApiProperty({ example: "Ada Lovelace", description: "Display name." })
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(20)
name: string;
@ApiProperty({ example: "ada@example.com", description: "Unique email." })
@IsString()
@IsNotEmpty()
@IsEmail()
email: string;
@ApiProperty({
example: "correct-horse-battery-staple",
description: "Plain password (hash before persist).",
minLength: 8,
})
@IsString()
@IsNotEmpty()
@MinLength(8)
password: string;
}
The controller takes a RegisterDto, not CreateUserDto directly. Same fields, but keeping it a separate type means the registration endpoint can diverge from admin-side user creation later without the two fighting over one shared DTO:
import { CreateUserDto } from "@/api/user/dto/create-user.dto";
export class RegisterDto extends CreateUserDto {}
Nest's global ValidationPipe rejects the request before it ever reaches the controller if any of these fail:
class AuthController {
// ...
@Post("register")
@HttpCode(HttpStatus.CREATED)
public async register(
@Body() registerDto: RegisterDto,
@Res({ passthrough: true }) res: Response
) {
return this.authService.register(res, registerDto);
}
}
import { hash } from "argon2";
class AuthService {
// ...
public async register(res: Response, registerDto: RegisterDto) {
const { password, ...userData } = registerDto;
const hashedPassword = await hash(password);
const user = await this.userService.create({
...userData,
password: hashedPassword,
});
const { accessToken, refreshToken } = await this.generateTokens(user.id);
this.setAuthCookies(res, accessToken, refreshToken);
return { user: toSafeUser(user) };
}
}
generateTokens is the one place that actually signs the pair from Step 1 — this is where the two expiration times from Step 3 get used, and where the tokenType field the strategy checks comes from:
class AuthService {
// ...
private async generateTokens(userId: string) {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ userId, tokenType: "access" } satisfies JWTAccessTokenPayload,
{ expiresIn: this.JWT_EXPIRATION_TIME }
),
this.jwtService.signAsync(
{ userId, tokenType: "refresh" } satisfies JWTAccessTokenPayload,
{ expiresIn: this.JWT_REFRESH_EXPIRATION_TIME }
),
]);
return { accessToken, refreshToken };
}
}
Same payload shape both times, just a different tokenType and a different expiresIn — that one field is what validate and refresh each check for, in opposite directions.
userService.create() returns the full row, password included — Prisma just hands back what it inserted. toSafeUser is what strips it before the response goes out, mirroring the same fields as userSelect from Step 2, just applied to a value already in memory instead of a query:
export function toSafeUser(user: UserWithPassword): User {
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
Step 7: Login — Why Cookies, Not localStorage
A smaller DTO than registration's — just the two fields needed to check a login attempt:
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
export class LoginDto {
@ApiProperty({ example: "ada@example.com", description: "Account email." })
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({ example: "correct-horse-battery-staple", description: "Plain password." })
@IsString()
@IsNotEmpty()
password: string;
}
Same shape as registration otherwise — the controller forwards, the service checks the credentials and issues tokens:
class AuthController {
// ...
@Post("login")
public async login(@Body() loginDto: LoginDto, @Res({ passthrough: true }) res: Response) {
return this.authService.login(res, loginDto);
}
}
import { verify } from "argon2";
class AuthService {
// ...
public async login(res: Response, loginDto: LoginDto) {
const user = await this.userService.findByEmail(loginDto.email);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
const passwordValid = await verify(user.password, loginDto.password);
if (!passwordValid) {
throw new UnauthorizedException("Invalid credentials");
}
const { accessToken, refreshToken } = await this.generateTokens(user.id);
this.setAuthCookies(res, accessToken, refreshToken);
return { user: toSafeUser(user) };
}
}
The interesting part is not the check itself — it is where the token ends up. The previous article returned the token in the response body and left it to the client to store, typically in localStorage. That is fine for a quick MVP, but it has a real downside: anything running as JavaScript on your page can read localStorage. If a single dependency, ad script, or injected <script> tag manages to run on your page — an XSS bug — it can read the token and send it wherever it wants.
An httpOnly cookie is not readable by JavaScript at all. The browser attaches it to requests automatically; your frontend code never touches it. Combined with secure (HTTPS only) and sameSite, this is the standard defense against token theft via XSS — and it is also why the strategy in Step 5 reads the token from a cookie instead of a header:
class AuthService {
// ...
private setAuthCookies(res: Response, accessToken: string, refreshToken: string): void {
res.cookie(ACCESS_TOKEN_COOKIE, accessToken, {
...this.getAuthCookieOptions("/"),
maxAge: ms(this.JWT_EXPIRATION_TIME),
});
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
...this.getAuthCookieOptions("/auth/refresh"),
maxAge: ms(this.JWT_REFRESH_EXPIRATION_TIME),
});
}
private clearAuthCookies(res: Response): void {
res.clearCookie(ACCESS_TOKEN_COOKIE, this.getAuthCookieOptions("/"));
res.clearCookie(REFRESH_TOKEN_COOKIE, this.getAuthCookieOptions("/auth/refresh"));
}
private getAuthCookieOptions(path: string): CookieOptions {
return {
path,
httpOnly: true,
secure: !IS_DEV_ENV,
domain: this.COOKIE_DOMAIN,
sameSite: IS_DEV_ENV ? "lax" : "none",
};
}
}
accessToken gets path: "/", so it goes out with every request. refreshToken gets its own, narrower path: "/auth/refresh", so the browser only ever sends it to that one route.
Step 8: Protecting Routes with Guards and Decorators
Running the strategy from Step 5 against a route is wrapped into a decorator, so a controller never has to know the underlying strategy name:
import { applyDecorators, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
export const Authorization = () => {
return applyDecorators(UseGuards(AuthGuard("jwt")));
};
Any protected endpoint, in any module, becomes a one-liner:
class UserController {
// ...
@Authorization()
@Get("me")
public async me(@Req() req: Request) {
return req.user;
}
}
For pulling a specific field off the user instead of the whole object, a small param decorator saves repeating req.user as User everywhere:
import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import type { User } from "@/api/user/user.service";
export const AuthorizedUser = createParamDecorator((data: keyof User, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
const user = request.user as User;
return data ? user?.[data] : request.user;
});
Which reads cleanly in any controller that needs to know who is calling it, without pulling in the whole Request object:
class UserController {
// ...
@Authorization()
@Get("me/settings")
public async getSettings(@AuthorizedUser("id") userId: string) {
return this.userService.getSettings(userId);
}
}
Step 9: Refresh Tokens
An access token that lives for hours is a bigger risk if it leaks than one that lives for minutes. But short-lived tokens mean the user would have to log in again every few minutes, which is not acceptable. The refresh token solves that: the client silently exchanges it for a new access/refresh pair when the access token expires. The refresh token itself comes from the cookie, not the request body:
class AuthController {
// ...
@Post("refresh")
public async refresh(
@Res({ passthrough: true }) res: Response,
@Cookies("refreshToken") refreshToken: string
) {
return this.authService.refresh(res, refreshToken);
}
}
class AuthService {
// ...
public async refresh(res: Response, refreshToken: string): Promise<void> {
if (!refreshToken) {
throw new UnauthorizedException("Refresh token is required");
}
let payload: JWTAccessTokenPayload;
try {
payload = await this.jwtService.verifyAsync<JWTAccessTokenPayload>(refreshToken);
} catch {
throw new UnauthorizedException("Invalid or expired refresh token");
}
if (payload.tokenType !== "refresh") {
throw new UnauthorizedException("Invalid token type");
}
const user = await this.userService.findByIdForAuth(payload.userId);
const { accessToken, refreshToken: newRefreshToken } = await this.generateTokens(user.id);
this.setAuthCookies(res, accessToken, newRefreshToken);
}
}
Step 10: Logout
Logging out just means clearing the cookies:
class AuthController {
// ...
@Post("logout")
@HttpCode(HttpStatus.NO_CONTENT)
public logout(@Res({ passthrough: true }) res: Response) {
this.authService.logout(res);
}
}
class AuthService {
// ...
public logout(res: Response): void {
this.clearAuthCookies(res);
}
}
Step 11: Caching the User Lookup in Redis
Every protected request calls JwtStrategy.validate, which hits Postgres for the same user row again and again. Redis fixes that: check the cache first, query Postgres only on a miss.
class UserService {
private readonly USER_CACHE_TTL_SECONDS = 300;
// ...
public async findByIdForAuth(id: string) {
const user = await this.redisService.retrieve({
key: `user:id:${id}`,
ttl: this.USER_CACHE_TTL_SECONDS,
strategy: () =>
this.prismaService.user.findUnique({
where: { id },
select: userSelect,
}),
});
if (!user) {
throw new NotFoundException("User not found");
}
return user;
}
}
5 minutes is short enough that a change to the user — a name update, an account being disabled — does not stay stale for long, but long enough to save most of the repeat lookups.
What Is Next
What is described above is already production-ready authentication, built on tools that are the current standard for a NestJS backend. It holds up fine as is.
It can still be improved, of course, depending on what the project actually needs.
One thing worth adding is sessions. Instead of a userId, the token payload would carry a sessionId. Every login becomes its own session, and a user can have several at once — one per device, one per browser. If a phone gets stolen, you can log out that one session specifically, without touching the others.