Photo by Eric Er on Unsplash

On this page

This chapter follows MongoDB with Mongoose in NestJS.

Authentication answers “who is making this request?”. In a Nest app the answer is produced by a guard, because a guard runs before the handler, can reject the request, and can see which routes are marked public. The guard puts the authenticated user on request.user, where later guards, interceptors, and handlers can read it. Passport supplies the actual checks as interchangeable strategies, and @nestjs/passport turns each strategy into a Nest guard.

How the pieces fit

The setup below has two flows. Signing in exchanges an email and password for a JSON Web Token. Every later request sends that token in the Authorization header.

Diagram 1
sequenceDiagram
  participant Client
  participant Local as LocalAuthGuard
  participant Jwt as JwtAuthGuard
  participant Auth as AuthService
  participant Handler as Route handler
  Client->>Local: POST /auth/login with email and password
  Local->>Auth: validateUser(email, password)
  Auth-->>Local: user or null
  Local->>Handler: request.user is set
  Handler-->>Client: accessToken
  Client->>Jwt: GET /cats with Bearer token
  Jwt->>Jwt: verify signature and expiry
  Jwt->>Handler: request.user from token payload
  Handler-->>Client: 200 with data

The token is signed, not encrypted. Anyone holding it can read the payload, so it carries an ID and roles, never a password or other secrets. JWT, JWS, JWE, and JOSE explains the format.

Installing Passport and JWT

Terminal window
pnpm add @nestjs/passport passport passport-local @nestjs/jwt passport-jwt argon2
pnpm add -D @types/passport-local @types/passport-jwt

argon2 hashes passwords. Users are stored with a hash, never with the password itself, and a sign-in compares against the hash.

Checking credentials with the local strategy

AuthService holds the logic. It looks the user up and verifies the password:

src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import { UsersService } from '../users/users.service';
import type { AuthUser } from './auth-user';
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
) {}
async validateUser(email: string, password: string): Promise<AuthUser | null> {
const user = await this.users.findByEmail(email);
if (!user || !(await argon2.verify(user.passwordHash, password))) return null;
return { id: user.id, email: user.email, roles: user.roles };
}
login(user: AuthUser) {
return { accessToken: this.jwt.sign({ sub: user.id, email: user.email, roles: user.roles }) };
}
}

A strategy class connects Passport to it. PassportStrategy(Strategy) registers the strategy under its default name, local. The options go to super(), and the object validate() returns becomes request.user:

src/auth/local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
import type { AuthUser } from './auth-user';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly auth: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string): Promise<AuthUser> {
const user = await this.auth.validateUser(email, password);
if (!user) throw new UnauthorizedException('Invalid email or password');
return user;
}
}

The error message deliberately does not say whether the email or the password was wrong, so the endpoint cannot be used to discover which emails have accounts.

Issuing a JWT

AuthGuard('local') from @nestjs/passport is the guard that runs the strategy. A named subclass avoids repeating the string:

src/auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

The login route only runs when the guard has accepted the credentials, so the handler just signs a token for request.user:

src/auth/auth.controller.ts
import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import type { AuthUser } from './auth-user';
import { LocalAuthGuard } from './local-auth.guard';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(200)
login(@Req() req: Request & { user: AuthUser }) {
return this.auth.login(req.user);
}
}

JwtModule signs with a secret from configuration. The same secret verifies the token later:

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '15m' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AuthModule {}

A short lifetime limits the damage of a leaked token, since a JWT cannot be withdrawn once issued. Longer sessions add a refresh token, stored server-side so that it can be revoked.

Validating the JWT on later requests

The JWT strategy reads the bearer token, checks its signature and expiry, and only then calls validate() with the decoded payload:

src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { AuthUser } from './auth-user';
interface JwtPayload {
sub: string;
email: string;
roles: AuthUser['roles'];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
});
}
validate(payload: JwtPayload): AuthUser {
return { id: payload.sub, email: payload.email, roles: payload.roles };
}
}

By the time validate() runs, the token is known to be genuine and current. It is the place to look the user up again if the app needs fresh data, or to check a list of revoked token IDs. A request with a missing, expired, or tampered token is rejected with 401 before any handler runs.

Protecting every route by default

Putting @UseGuards(JwtAuthGuard) on each controller works until someone forgets one. The safer default is the global guard registered with APP_GUARD in AuthModule above, combined with a decorator that marks the few public routes:

src/auth/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
src/auth/jwt-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from './public.decorator';
@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 || super.canActivate(context);
}
}

The login route carries @Public(), so the global JWT guard lets it through and the route’s own LocalAuthGuard checks the password instead. A new controller is protected the moment it exists. For GraphQL resolvers the same guard has to read the request through GqlExecutionContext by overriding getRequest(), as described in Authentication and authorization in GraphQL.

Knowing who the user is does not yet say what they may do. That is the next chapter.

Next: Authorization in NestJS.

Check your understanding

Question 1 of 6

0 of 6 checked

  1. 1. Why must a JWT payload never contain a password?

Search articles

Type a word to search published articles.

Open the search page