Skip to content
NestJS ns auth 4 min read

Local Strategy & Login

The local strategy is how you authenticate the classic username-and-password login form in NestJS. It wraps passport-local, reads credentials from the request body, and hands them to a validate() method where you check them against your user store. Because the heavy lifting — parsing the body, short-circuiting on failure — is handled for you, your job reduces to one thing: confirming the supplied password matches the stored hash. This page builds a complete login flow end to end.

Installing the dependencies

The local strategy needs the Passport core packages plus the passport-local strategy and its types. If you are also issuing JWTs after login (the usual case) pull those in too.

npm install @nestjs/passport passport passport-local
npm install -D @types/passport-local

Validating credentials in the service

Authentication logic belongs in a service, not the strategy — the strategy is just an adapter. The AuthService.validateUser() method looks the user up by username and compares the submitted password to the stored bcrypt hash. It returns the user (minus the hash) on success and null on failure. Never throw here based on which check failed; treat “no such user” and “wrong password” identically so attackers cannot enumerate accounts.

import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  async validateUser(
    username: string,
    password: string,
  ): Promise<Omit<User, 'passwordHash'> | null> {
    const user = await this.usersService.findByUsername(username);
    if (!user) {
      return null;
    }

    const passwordMatches = await bcrypt.compare(password, user.passwordHash);
    if (!passwordMatches) {
      return null;
    }

    const { passwordHash, ...safeUser } = user;
    return safeUser;
  }
}

Tip: Always run bcrypt.compare() even when the lookup fails by comparing against a dummy hash, or the difference in response time leaks whether a username exists. A simpler mitigation is the generic 401 message used below.

Implementing the local strategy

Subclass PassportStrategy(Strategy) from passport-local. By default the strategy reads the fields username and password from the request body. If your form uses email, pass { usernameField: 'email' } to super(). Whatever validate() returns is attached to request.user; returning a falsy value or throwing causes Passport to deny the request.

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super(); // use super({ usernameField: 'email' }) for email logins
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }
    return user; // becomes request.user
  }
}

Wrapping the strategy in a guard

The login route is protected by a guard that triggers the 'local' strategy. Subclassing AuthGuard('local') is optional but recommended — it gives you a named class you can extend later and keeps controllers readable.

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

The login route

Apply LocalAuthGuard to the POST /auth/login handler. The guard runs before the handler: it pulls credentials from the body, invokes LocalStrategy.validate(), and on success populates req.user. By the time your handler body runs, the user is already authenticated, so you simply mint and return a token.

import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';
import { AuthService } from './auth.service';

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

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

The login() method on the service signs a JWT from the authenticated user:

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  async login(user: { id: number; username: string }) {
    const payload = { sub: user.id, username: user.username };
    return { access_token: await this.jwtService.signAsync(payload) };
  }
}

Wiring up the module

Register the strategy as a provider so Nest can resolve its dependencies, and import PassportModule.

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [PassportModule, UsersModule],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

Trying it out

A correct login returns a signed token; a wrong password returns a generic 401.

curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"ada","password":"correct-horse"}'

Output:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsInVzZ..."}
$ curl -i -X POST http://localhost:3000/auth/login -d '{"username":"ada","password":"wrong"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"statusCode":401,"message":"Invalid credentials"}

Best Practices

  • Keep the credential check in a service method; the strategy should only adapt Passport to it.
  • Compare with bcrypt.compare() (or argon2) — never decrypt or string-compare passwords.
  • Return a single generic 401 message for both unknown users and bad passwords to prevent account enumeration.
  • Strip the password hash from any object you return so it never leaks into responses or tokens.
  • Use usernameField/passwordField options instead of renaming your form fields to match Passport.
  • Validate and rate-limit the login route to blunt brute-force attempts.
  • Serve the login endpoint over HTTPS only, since credentials travel in the request body.
Last updated June 14, 2026
Was this helpful?