On this page

JWT authentication with Passport in NestJS

15 min read TextCh. 4 — Security and Communication

JWT authentication overview

JSON Web Tokens (JWT) are the standard mechanism for stateless authentication in REST APIs. A JWT is a signed, base64-encoded string containing a payload of claims (user ID, role, expiry). Because the server signs the token with a secret, it can verify authenticity without querying the database on every request.

The typical flow:

  1. Client sends credentials (email + password)
  2. Server validates credentials and returns a signed JWT
  3. Client stores the JWT and sends it in the Authorization: Bearer <token> header on subsequent requests
  4. Server verifies the signature and extracts the user from the payload

Installing the required packages

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt

The User entity

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column({ select: false }) // Exclude from queries by default
  password: string;

  @Column({ default: 'user' })
  role: string;

  @Column({ nullable: true })
  firstName: string | null;

  @Column({ nullable: true })
  lastName: string | null;

  @CreateDateColumn()
  createdAt: Date;
}

The select: false on password means it will not be included in query results unless explicitly requested — an important security measure.

Registration and login DTOs

// register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';

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

  @IsString()
  @MinLength(8)
  @MaxLength(72)
  password: string;

  @IsString()
  @MinLength(2)
  firstName: string;
}

// login.dto.ts
export class LoginDto {
  @IsEmail()
  email: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}

The auth controller

import { Controller, Post, Body, HttpCode, HttpStatus, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';

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

  @Public()
  @Post('register')
  register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @Public()
  @Post('login')
  @HttpCode(HttpStatus.OK)
  login(@Body() dto: LoginDto) {
    return this.authService.login(dto);
  }

  @Get('me')
  getProfile(@CurrentUser() user: JwtPayload) {
    return user;
  }
}

interface JwtPayload {
  sub: number;
  email: string;
  role: string;
}

The JWT guard

The AuthGuard from the previous lesson now works with the JWT strategy:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractToken(request);
    if (!token) throw new UnauthorizedException('No token provided');

    try {
      const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
        secret: process.env['JWT_SECRET'],
      });
      (request as Record<string, unknown>)['user'] = payload;
      return true;
    } catch {
      throw new UnauthorizedException('Invalid or expired token');
    }
  }

  private extractToken(request: Request): string | undefined {
    const header = (request as Record<string, unknown>)['headers'] as Record<string, string>;
    const [type, token] = (header['authorization'] ?? '').split(' ');
    return type === 'Bearer' ? token : undefined;
  }
}

Refresh tokens

Access tokens expire quickly. Refresh tokens let clients obtain new access tokens without re-entering credentials.

interface TokenPair {
  accessToken: string;
  refreshToken: string;
}

@Injectable()
export class AuthService {
  // ...
  private async generateTokens(user: User): Promise<TokenPair> {
    const payload = { sub: user.id, email: user.email, role: user.role };

    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(payload, { expiresIn: '15m' }),
      this.jwtService.signAsync(
        { sub: user.id, type: 'refresh' },
        { expiresIn: '7d', secret: process.env['JWT_REFRESH_SECRET'] },
      ),
    ]);

    // Store the hashed refresh token in the database
    await this.usersRepo.update(user.id, {
      refreshTokenHash: await bcrypt.hash(refreshToken, 10),
    });

    return { accessToken, refreshToken };
  }

  async refreshTokens(refreshToken: string): Promise<TokenPair> {
    let payload: { sub: number; type: string };
    try {
      payload = await this.jwtService.verifyAsync(refreshToken, {
        secret: process.env['JWT_REFRESH_SECRET'],
      });
    } catch {
      throw new UnauthorizedException('Invalid refresh token');
    }

    if (payload.type !== 'refresh') {
      throw new UnauthorizedException('Wrong token type');
    }

    const user = await this.usersRepo.findOne({
      where: { id: payload.sub },
      select: { id: true, email: true, role: true, refreshTokenHash: true },
    });

    if (!user?.refreshTokenHash) throw new UnauthorizedException();

    const isValid = await bcrypt.compare(refreshToken, user.refreshTokenHash);
    if (!isValid) throw new UnauthorizedException('Refresh token revoked');

    return this.generateTokens(user);
  }

  async logout(userId: number): Promise<void> {
    await this.usersRepo.update(userId, { refreshTokenHash: null });
  }
}

Password change

async changePassword(
  userId: number,
  currentPassword: string,
  newPassword: string,
): Promise<void> {
  const user = await this.usersRepo.findOne({
    where: { id: userId },
    select: { id: true, password: true },
  });
  if (!user) throw new NotFoundException();

  const isValid = await bcrypt.compare(currentPassword, user.password);
  if (!isValid) throw new UnauthorizedException('Incorrect current password');

  const hash = await bcrypt.hash(newPassword, 12);
  await this.usersRepo.update(userId, {
    password: hash,
    refreshTokenHash: null, // Invalidate all refresh tokens
  });
}

Email verification flow

A production-ready registration flow includes email verification:

  1. User registers → generate a random token, store its hash, send an email with the verification link
  2. User clicks the link → verify the token, mark the user as verified
  3. Restrict access to unverified users via a guard
async sendVerificationEmail(userId: number): Promise<void> {
  const token = randomBytes(32).toString('hex');
  const hash = createHash('sha256').update(token).digest('hex');
  const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

  await this.usersRepo.update(userId, {
    emailVerificationHash: hash,
    emailVerificationExpires: expires,
  });

  await this.mailerService.sendVerificationEmail(token);
}
Short access token expiry
Always set a short expiry on access tokens (15 minutes is common). Pair them with refresh tokens that have a longer expiry (7-30 days). This limits the damage if an access token is stolen — it becomes invalid quickly — while still providing a smooth user experience via silent refresh.
Never store JWT_SECRET in code
The JWT secret must come from an environment variable, not be hardcoded. A compromised secret allows anyone to forge tokens. Use a cryptographically random string of at least 32 characters: openssl rand -base64 32.
import {
  Injectable,
  UnauthorizedException,
  ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './user.entity';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepo: Repository<User>,
    private readonly jwtService: JwtService,
  ) {}

  async register(dto: RegisterDto): Promise<{ accessToken: string }> {
    const exists = await this.usersRepo.findOneBy({ email: dto.email });
    if (exists) throw new ConflictException('Email already registered');

    const hash = await bcrypt.hash(dto.password, 12);
    const user = this.usersRepo.create({ ...dto, password: hash });
    await this.usersRepo.save(user);

    return this.generateTokens(user);
  }

  async login(dto: LoginDto): Promise<{ accessToken: string }> {
    const user = await this.usersRepo.findOneBy({ email: dto.email });
    const isValid = user && await bcrypt.compare(dto.password, user.password);

    if (!isValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    return this.generateTokens(user);
  }

  private generateTokens(user: User): { accessToken: string } {
    const payload = { sub: user.id, email: user.email, role: user.role };
    const accessToken = this.jwtService.sign(payload);
    return { accessToken };
  }
}