NestJS course Β· Module 4: Authentication

Authentication - identifying the legionaries

5 min read
In this lesson5

A man in Roman armour walks up to the camp gate. The armour proves nothing - it can be bought, or stripped from the fallen. Before the sentry lets him in, he must answer one question: who exactly are you?

We call that question authentication. Do not confuse it with the question that comes later, at the treasury door: what are you allowed to do? - that is authorization. The order cannot be reversed: first we establish identity, only then privileges. You cannot check the rank of someone you have not yet recognised.

In this module we will build the whole gate. This lesson is its foundation: the legion's register and the safe admission of recruits.

Packages - equipping the guardhouse

Let's start with the tools we will need throughout the module:

1npm install @nestjs/passport passport passport-jwt @nestjs/jwt

passport is the authentication library itself, @nestjs/passport wires it into NestJS. @nestjs/jwt issues passes, and passport-jwt teaches the sentry to read them. For now we are only installing them - you will meet each in turn in the coming lessons.

The legion's register - the User entity

Every legionary must have an entry in the register:

1@Entity('users')
2export class User {
3  @PrimaryGeneratedColumn()
4  id: number;
5
6  @Column({ unique: true })
7  username: string;
8
9  @Column({ unique: true })
10  email: string;
11
12  @Column()
13  @Exclude()
14  password: string;
15}

You read entities comfortably by now - that is knowledge from the TypeORM module. unique: true on username and email guarantees that two legionaries cannot enlist under the same name.

Let's pause at @Exclude(), because it is the only new element here and at the same time the most common security hole in beginner applications. This decorator does not remove the field from the database and encrypts nothing - the password still sits in its column. It acts on the way out: when NestJS turns an entity into a JSON response, a field marked @Exclude() is left out.

Without it, a plain GET /users/1 would send the password hash back to the client along with the rest of the data. Nobody notices in testing, because the application works correctly - and passwords leak on every request. Remember this rule: a field that must never leave the server gets @Exclude() the moment you create it, not sometime later.

Checking papers at the entrance - the DTO

Before data reaches the register, it has to be inspected. A DTO with validation rules does that:

1export class RegisterDto {
2  @IsNotEmpty()
3  username: string;
4
5  @IsNotEmpty()
6  @IsEmail()
7  email: string;
8
9  @IsNotEmpty()
10  @MinLength(8)
11  password: string;
12}

Note the order of the checks - it runs from the most basic to the most specific. First @IsNotEmpty() asks whether the field was filled in at all. Then @IsEmail() examines whether what was typed has the shape of an address. Finally @MinLength(8) imposes a requirement on the content.

That order has a practical point: there is no sense examining the format of an empty field. And one more distinction, because it tends to confuse. All these decorators inspect the request alone - they look only at what arrived and know nothing about the database. The question "is this username already taken?" requires looking into the register, so no decorator can ask it. That check belongs to the service, and you are about to see it.

Admitting a recruit

Registration is four steps in a fixed order:

1async register(dto: RegisterDto): Promise<User> {
2  const existing = await this.userRepository.findOne({
3    where: { username: dto.username },
4  });
5
6  if (existing) {
7    throw new ConflictException('A legionary by that name already serves');
8  }
9
10  const hashedPassword = await bcrypt.hash(dto.password, 10);
11
12  return this.userRepository.save({
13    ...dto,
14    password: hashedPassword,
15  });
16}

Let's walk through them. Validation already happened, automatically - the DTO data arrived here checked. The uniqueness check is the database query no decorator could perform; on a collision we throw ConflictException, that is a 409 response. Hashing the password - and only now the save.

The order of the last two steps is non-negotiable: the password becomes a hash before the save, never after. Nothing readable ever reaches the database.

What actually is this bcrypt.hash(dto.password, 10)? Hashing is a one-way transformation - a string emerges from the password, and there is no way back to the original. It is not encryption, because encryption can by definition be reversed with a key. So at login we decrypt nothing; we hash the supplied password again and compare the results with bcrypt.compare(). Even you, with full access to the database, cannot read your user's password - and that is exactly the point.

The number 10 is the salt rounds, controlling the cost of the computation. We will return to it and to the rest of bcrypt's mechanics in a separate lesson on hashing.

Summary

The gate stands, the register works, recruits enlist safely:

  • authentication answers who the visitor is; authorization - what he may do; the first always precedes the second,
  • the module rests on four packages: passport, @nestjs/passport, @nestjs/jwt, passport-jwt,
  • @Exclude() does not touch the database - it only prevents a field from being sent in an API response; without it passwords leak on every GET,
  • DTO validation runs from general to specific: @IsNotEmpty, then @IsEmail, then @MinLength,
  • decorators see the request's contents only - a uniqueness check needs a database query and belongs to the service,
  • registration has four steps: validate, check existence, hash, save,
  • a hash is one-way: we never decrypt passwords, we hash again and compare with bcrypt.compare().

In the next lesson we will take hashing apart - you will learn what a salt is and why slow hashing can be a virtue. For now remember: authentication asks "who are you", and the password does not exist in the legion's register - only its one-way imprint does.

Code for this lesson: src/auth/auth-basics.ts
1// Authentication - Identifying Legionaries
2// Roman Imperium - identity verification system
3import { Injectable, UnauthorizedException } from '@nestjs/common';
4import { JwtService } from '@nestjs/jwt';
5import * as bcrypt from 'bcrypt';
6
7// User interface in the Imperium system
8interface Legionary {
9  id: string;
10  username: string;
11  passwordHash: string;
12  rank: 'miles' | 'centurion' | 'legatus' | 'consul';
13}
14
15@Injectable()
16export class AuthService {
17  // Simulation of the legionaries database
18  private legionaries: Legionary[] = [];
19
20  constructor(private jwtService: JwtService) {}
21
22  // Registration of a new legionary
23  async register(username: string, password: string) {
24    // Password hashing - like a wax seal on a document
25    const saltRounds = 10;
26    const passwordHash = await bcrypt.hash(password, saltRounds);
27
28    const newLegionary: Legionary = {
29      id: Date.now().toString(),
30      username,
31      passwordHash,
32      rank: 'miles', // New recruit
33    };
34
35    this.legionaries.push(newLegionary);
36    console.log('New legionary registered:', username);
37
38    return { message: 'Registration completed successfully' };
39  }
40
41  // Legionary validation - identity checking
42  async validateUser(username: string, password: string) {
43    const legionary = this.legionaries.find(l => l.username === username);
44
45    if (!legionary) {
46      throw new UnauthorizedException('Unknown legionary!');
47    }
48
49    // Compare the password with the hash
50    const isPasswordValid = await bcrypt.compare(
51      password,
52      legionary.passwordHash
53    );
54
55    if (!isPasswordValid) {
56      throw new UnauthorizedException('Wrong password!');
57    }
58
59    return legionary;
60  }
61
62  // Login - issuing a pass (token)
63  async login(username: string, password: string) {
64    const legionary = await this.validateUser(username, password);
65
66    // JWT token payload
67    const payload = {
68      sub: legionary.id,
69      username: legionary.username,
70      rank: legionary.rank,
71    };
72
73    return {
74      access_token: this.jwtService.sign(payload),
75      legionary: {
76        id: legionary.id,
77        username: legionary.username,
78        rank: legionary.rank,
79      },
80    };
81  }
82}
83
84console.log('Authentication: register -> validate -> login');
85console.log('Passwords are hashed with bcrypt - never store as plain text!');
86

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is Authentication in the context of a web application?

  2. 2. What is the difference between Authentication and Authorization?

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the installation command for @nestjs/passport, passport, passport-jwt, and @nestjs/jwt in a NestJS project

  • Click in order

    Arrange the elements of the User entity definition in the correct order

  • Vertical ordering

    Order the registration data validation steps from most basic to most specific

  • Code editor

    Complete the AuthService with a hashPassword method that takes a password and returns the hashed version using bcrypt

  • Vertical ordering

    Order the steps of the user registration process in NestJS

Useful articles