NestJS Authentication with PostgreSQL, JWT & Refresh Tokens

NestJS authentication looks simple in most tutorials: create a login endpoint, compare a password, generate a JWT, and protect a route. That is enough to understand the basic idea, but it is not enough for a real application. Once authentication reaches production, you also need to think about password hashing, database constraints, token expiration, refresh…

NestJS
guide.md READY

NestJS authentication looks simple in most tutorials: create a login endpoint, compare a password, generate a JWT, and protect a route.

That is enough to understand the basic idea, but it is not enough for a real application.

Once authentication reaches production, you also need to think about password hashing, database constraints, token expiration, refresh tokens, logout, token revocation, validation, authorization, secrets, and what happens when a user changes their password or loses access to an account.

In this guide, I’ll build a practical authentication flow using NestJS, PostgreSQL, TypeORM, JWT access tokens, refresh tokens, DTO validation, and guards. More importantly, I’ll explain the decisions I would make when building this for a real project rather than stopping at tutorial-level code.

Table of Contents

What We Are Building

Our authentication API will support:

POST /api/auth/register
POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout

GET  /api/auth/profile
GET  /api/admin/users

The basic request flow will look like this:

User registers
      ↓
Validate request
      ↓
Hash password
      ↓
Save user in PostgreSQL
      ↓
Login
      ↓
Verify password
      ↓
Create access token
      +
Create refresh token
      ↓
Protected routes use access token
      ↓
Expired access token?
      ↓
Refresh endpoint creates a new token

Authentication vs Authorization

These two terms are often mixed together, but they solve different problems.

ConceptQuestion it answers
AuthenticationWho is this user?
AuthorizationWhat is this user allowed to do?

A valid JWT may prove that a user is authenticated. It does not automatically mean that user should be allowed to delete another account, view admin reports, or change system settings.

I keep these responsibilities separate because authorization requirements usually become more complex as an application grows.

Step 1: Install the Required Packages

For this example, we will use PostgreSQL and TypeORM.

npm install @nestjs/typeorm typeorm pg
npm install @nestjs/config
npm install @nestjs/jwt
npm install class-validator class-transformer
npm install argon2

The responsibilities are roughly:

  • @nestjs/typeorm and typeorm — database integration
  • pg — PostgreSQL driver
  • @nestjs/config — environment configuration
  • @nestjs/jwt — JWT signing and verification
  • class-validator — incoming request validation
  • argon2 — password hashing

Step 2: Configure Environment Variables

I never hard-code production database credentials or JWT secrets directly in the application source.

Create a .env file for local development:

PORT=3000

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=nestjs_auth
DATABASE_USER=postgres
DATABASE_PASSWORD=your_password

JWT_ACCESS_SECRET=replace_with_a_long_random_secret
JWT_REFRESH_SECRET=replace_with_another_long_random_secret

JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

In production, I prefer secrets to come from the deployment environment or a secrets-management service rather than from a file committed to the repository.

Your .env file should also be excluded from Git.

.env
.env.local
.env.production

Step 3: Configure PostgreSQL with TypeORM

Configure the database through environment variables rather than embedding credentials in AppModule.

import { Module } from '@nestjs/common';
import {
  ConfigModule,
  ConfigService,
} from '@nestjs/config';
import {
  TypeOrmModule,
} from '@nestjs/typeorm';

import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),

    TypeOrmModule.forRootAsync({
      inject: [ConfigService],

      useFactory: (
        config: ConfigService,
      ) => ({
        type: 'postgres',

        host:
          config.get<string>(
            'DATABASE_HOST'
          ),

        port:
          config.get<number>(
            'DATABASE_PORT'
          ),

        username:
          config.get<string>(
            'DATABASE_USER'
          ),

        password:
          config.get<string>(
            'DATABASE_PASSWORD'
          ),

        database:
          config.get<string>(
            'DATABASE_NAME'
          ),

        autoLoadEntities: true,

        synchronize: false,
      }),
    }),

    UsersModule,
    AuthModule,
  ],
})
export class AppModule {}

Notice that I use:

synchronize: false

I would not depend on automatic schema synchronization in production. Database changes should be controlled through migrations so that changes are visible, reviewable, and repeatable.

Step 4: Create the User Entity

Our user entity needs enough information to support authentication without storing unnecessary secrets.

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

export enum UserRole {
  USER = 'user',
  ADMIN = 'admin',
}

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

  @Column({
    length: 100,
  })
  name: string;

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

  @Column({
    select: false,
  })
  passwordHash: string;

  @Column({
    type: 'enum',
    enum: UserRole,
    default: UserRole.USER,
  })
  role: UserRole;

  @Column({
    nullable: true,
    select: false,
  })
  refreshTokenHash:
    string | null;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

There are two details here that I consider important.

First, the email column is unique:

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

The database should help enforce business invariants rather than trusting application code alone.

Second, password and refresh-token hashes use:

select: false

That reduces the chance of accidentally returning sensitive authentication fields from a normal user query.

Step 5: Create Registration and Login DTOs

Authentication endpoints are security boundaries, so I validate the request before it reaches the service layer.

Create register.dto.ts:

import {
  IsEmail,
  IsString,
  MaxLength,
  MinLength,
} from 'class-validator';

export class RegisterDto {
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(10)
  @MaxLength(128)
  password: string;
}

Create login.dto.ts:

import {
  IsEmail,
  IsString,
} from 'class-validator';

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

  @IsString()
  password: string;
}

Step 6: Enable Global Validation

I prefer enabling NestJS validation globally so every DTO receives the same basic protection.

import {
  ValidationPipe,
} from '@nestjs/common';
import {
  NestFactory,
} from '@nestjs/core';

import {
  AppModule,
} from './app.module';

async function bootstrap() {
  const app =
    await NestFactory.create(
      AppModule
    );

  app.setGlobalPrefix('api');

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    })
  );

  await app.listen(
    process.env.PORT ?? 3000
  );
}

bootstrap();

This gives us several protections:

  • DTO validation happens automatically.
  • Unexpected fields can be rejected.
  • Incoming values can be transformed into expected types.

I prefer rejecting unknown fields on sensitive endpoints because silently accepting unexpected input can hide client mistakes.

Step 7: Build the Users Service

The authentication service should not need to understand every detail of TypeORM. I keep user persistence inside a users service.

import {
  Injectable,
} from '@nestjs/common';
import {
  InjectRepository,
} from '@nestjs/typeorm';
import {
  Repository,
} from 'typeorm';

import {
  User,
} from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly users:
      Repository<User>,
  ) {}

  findByEmail(
    email: string
  ) {
    return this.users.findOne({
      where: {
        email:
          email.toLowerCase(),
      },
    });
  }

  findByEmailWithSecrets(
    email: string
  ) {
    return this.users
      .createQueryBuilder('user')
      .addSelect(
        'user.passwordHash'
      )
      .addSelect(
        'user.refreshTokenHash'
      )
      .where(
        'LOWER(user.email) = LOWER(:email)',
        {
          email,
        }
      )
      .getOne();
  }

  create(data: Partial<User>) {
    const user =
      this.users.create(data);

    return this.users.save(user);
  }

  async updateRefreshToken(
    userId: number,
    hash: string | null
  ) {
    await this.users.update(
      userId,
      {
        refreshTokenHash: hash,
      }
    );
  }
}

The idea is that normal user queries do not automatically retrieve authentication secrets.

Step 8: Hash Passwords Before Saving Them

Never store a user’s raw password.

For this example, I am using Argon2.

import argon2 from 'argon2';

const passwordHash =
  await argon2.hash(
    dto.password
  );

The database stores:

$argon2id$...

not:

MyPassword123

During login, verify the supplied password against the hash:

const valid =
  await argon2.verify(
    user.passwordHash,
    dto.password
  );

if (!valid) {
  throw new UnauthorizedException(
    'Invalid email or password'
  );
}

I deliberately use the same public error message whether the email or password is wrong.

A login endpoint normally should not help an attacker determine which email addresses belong to real accounts.

Step 9: Register a User

Now we can implement registration.

import {
  ConflictException,
  Injectable,
} from '@nestjs/common';
import argon2 from 'argon2';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService:
      UsersService,
    private readonly jwtService:
      JwtService,
    private readonly config:
      ConfigService,
  ) {}

  async register(
    dto: RegisterDto
  ) {
    const email =
      dto.email
        .trim()
        .toLowerCase();

    const existingUser =
      await this.usersService
        .findByEmail(email);

    if (existingUser) {
      throw new ConflictException(
        'An account with this email already exists'
      );
    }

    const passwordHash =
      await argon2.hash(
        dto.password
      );

    const user =
      await this.usersService.create({
        name:
          dto.name.trim(),

        email,

        passwordHash,
      });

    return {
      id: user.id,
      name: user.name,
      email: user.email,
    };
  }
}

I still keep the database-level unique constraint on email even though we check for an existing user in application code.

Why both?

Because two registration requests can reach the server at nearly the same time. Application checks alone do not replace a database constraint.

Step 10: Configure JWT Access and Refresh Tokens

I prefer short-lived access tokens and longer-lived refresh tokens rather than creating an access token that remains valid for weeks.

Access token
→ short lifetime
→ sent with API requests

Refresh token
→ longer lifetime
→ used to request new access tokens

A compromised access token therefore has a smaller useful window.

Configure the JWT module using environment configuration:

import {
  JwtModule,
} from '@nestjs/jwt';

@Module({
  imports: [
    UsersModule,

    JwtModule.register({
      global: true,
    }),
  ],

  providers: [
    AuthService,
  ],

  controllers: [
    AuthController,
  ],
})
export class AuthModule {}

I am intentionally passing the secrets when signing rather than putting one shared hard-coded secret inside the module.

Step 11: Generate the Token Pair

private async createTokens(
  user: User
) {
  const payload = {
    sub: user.id,
    email: user.email,
    role: user.role,
  };

  const [
    accessToken,
    refreshToken,
  ] = await Promise.all([
    this.jwtService.signAsync(
      payload,
      {
        secret:
          this.config.getOrThrow(
            'JWT_ACCESS_SECRET'
          ),

        expiresIn:
          this.config.get(
            'JWT_ACCESS_EXPIRES_IN',
            '15m'
          ),
      }
    ),

    this.jwtService.signAsync(
      payload,
      {
        secret:
          this.config.getOrThrow(
            'JWT_REFRESH_SECRET'
          ),

        expiresIn:
          this.config.get(
            'JWT_REFRESH_EXPIRES_IN',
            '7d'
          ),
      }
    ),
  ]);

  return {
    accessToken,
    refreshToken,
  };
}

I keep JWT payloads relatively small.

A token is not where I would place complete user profiles, permissions lists, addresses, preferences, and other large data structures.

Step 12: Implement Login

async login(
  dto: LoginDto
) {
  const user =
    await this.usersService
      .findByEmailWithSecrets(
        dto.email
      );

  if (!user) {
    throw new UnauthorizedException(
      'Invalid email or password'
    );
  }

  const passwordValid =
    await argon2.verify(
      user.passwordHash,
      dto.password
    );

  if (!passwordValid) {
    throw new UnauthorizedException(
      'Invalid email or password'
    );
  }

  const tokens =
    await this.createTokens(
      user
    );

  const refreshTokenHash =
    await argon2.hash(
      tokens.refreshToken
    );

  await this.usersService
    .updateRefreshToken(
      user.id,
      refreshTokenHash
    );

  return tokens;
}

Notice something important here: I do not store the raw refresh token in the database.

I store a hash of it.

Client
→ receives refresh token

Database
→ stores hash of refresh token

This is similar to the thinking behind password storage: if the database is exposed, raw long-lived credentials should not be sitting there unnecessarily.

Step 13: Create the Authentication Controller

import {
  Body,
  Controller,
  HttpCode,
  HttpStatus,
  Post,
} from '@nestjs/common';

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

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

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

Your routes now become:

POST /api/auth/register
POST /api/auth/login

Step 14: Protect Routes with an Authentication Guard

A protected endpoint should reject requests that do not contain a valid access token.

Create an authentication guard:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import {
  ConfigService,
} from '@nestjs/config';
import {
  JwtService,
} from '@nestjs/jwt';
import type {
  Request,
} from 'express';

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

    private readonly config:
      ConfigService,
  ) {}

  async canActivate(
    context: ExecutionContext
  ) {
    const request =
      context
        .switchToHttp()
        .getRequest<Request>();

    const token =
      this.extractToken(
        request
      );

    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      const payload =
        await this.jwtService
          .verifyAsync(
            token,
            {
              secret:
                this.config
                  .getOrThrow(
                    'JWT_ACCESS_SECRET'
                  ),
            }
          );

      request['user'] =
        payload;

      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }

  private extractToken(
    request: Request
  ) {
    const [
      type,
      token,
    ] =
      request.headers
        .authorization
        ?.split(' ') ?? [];

    return type === 'Bearer'
      ? token
      : undefined;
  }
}

Step 15: Create a Protected Profile Route

import {
  Controller,
  Get,
  Req,
  UseGuards,
} from '@nestjs/common';

@Controller('auth')
export class AuthController {
  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(
    @Req() request
  ) {
    return {
      user:
        request.user,
    };
  }
}

The client sends:

Authorization: Bearer YOUR_ACCESS_TOKEN

Without a valid token, the route returns 401 Unauthorized.

Step 16: Implement Refresh Tokens

When the short-lived access token expires, I do not want the user to type their password again every 15 minutes.

The refresh endpoint handles this.

async refresh(
  refreshToken: string
) {
  let payload:
    {
      sub: number;
      email: string;
    };

  try {
    payload =
      await this.jwtService
        .verifyAsync(
          refreshToken,
          {
            secret:
              this.config
                .getOrThrow(
                  'JWT_REFRESH_SECRET'
                ),
          }
        );
  } catch {
    throw new UnauthorizedException(
      'Invalid refresh token'
    );
  }

  const user =
    await this.usersService
      .findByEmailWithSecrets(
        payload.email
      );

  if (
    !user ||
    !user.refreshTokenHash
  ) {
    throw new UnauthorizedException(
      'Invalid refresh token'
    );
  }

  const valid =
    await argon2.verify(
      user.refreshTokenHash,
      refreshToken
    );

  if (!valid) {
    throw new UnauthorizedException(
      'Invalid refresh token'
    );
  }

  const tokens =
    await this.createTokens(
      user
    );

  const newRefreshHash =
    await argon2.hash(
      tokens.refreshToken
    );

  await this.usersService
    .updateRefreshToken(
      user.id,
      newRefreshHash
    );

  return tokens;
}

This also rotates the refresh token instead of repeatedly returning the same credential.

Why I Prefer Refresh Token Rotation

A reusable refresh token that stays valid for months creates a larger security window if it is stolen.

A simplified rotation flow looks like this:

Refresh Token A
      ↓
Client requests refresh
      ↓
Verify A
      ↓
Issue:
Access Token B
Refresh Token B
      ↓
Replace stored hash
      ↓
Token A no longer matches

For higher-security systems, I would take this further with token-family tracking, reuse detection, per-device sessions, and server-side session records rather than one refresh-token hash directly on the user.

Step 17: Implement Logout

With this simplified approach, logout means invalidating the stored refresh credential.

async logout(
  userId: number
) {
  await this.usersService
    .updateRefreshToken(
      userId,
      null
    );

  return {
    message:
      'Logged out successfully',
  };
}

The current access token may remain valid until it expires.

That is one reason I keep access tokens short-lived.

Should JWTs Be Stored in localStorage?

This question does not have one universal answer because the correct storage model depends on your architecture and threat model.

For browser applications handling sensitive authentication, I am cautious about storing long-lived authentication credentials in JavaScript-accessible storage.

A common architecture is:

Access token
→ short-lived
→ memory / controlled client state

Refresh token
→ Secure
→ HttpOnly
→ SameSite cookie

An HttpOnly cookie prevents normal browser JavaScript from reading the refresh token, although cookie-based authentication introduces its own concerns such as CSRF configuration and cookie policy.

The important point is that token storage should be an explicit security decision not simply “put everything in localStorage because a tutorial did it.”

Step 18: Add Role-Based Authorization

Authentication tells us who the user is. Now we can decide what that user is allowed to do.

Suppose only administrators can list all users.

Create a roles decorator:

import {
  SetMetadata,
} from '@nestjs/common';

export const ROLES_KEY =
  'roles';

export const Roles =
  (...roles: string[]) =>
    SetMetadata(
      ROLES_KEY,
      roles
    );

Then create a guard:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
} from '@nestjs/common';
import {
  Reflector,
} from '@nestjs/core';

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

  canActivate(
    context: ExecutionContext
  ) {
    const requiredRoles =
      this.reflector
        .getAllAndOverride<string[]>(
          ROLES_KEY,
          [
            context.getHandler(),
            context.getClass(),
          ]
        );

    if (!requiredRoles) {
      return true;
    }

    const request =
      context
        .switchToHttp()
        .getRequest();

    return requiredRoles
      .includes(
        request.user.role
      );
  }
}

Now protect the route:

@Roles('admin')
@UseGuards(
  AuthGuard,
  RolesGuard
)
@Get('users')
findAllUsers() {
  return this.usersService
    .findAll();
}

For simple systems, RBAC is often enough.

For more complex applications, permissions may depend on ownership, organization membership, resource state, subscription plan, or other business rules. At that point, I usually move beyond a simple role string.

Step 19: Never Trust a Role Sent by the Client

One authentication mistake would be allowing a public registration request like:

{
  "name": "Attacker",
  "email": "attacker@example.com",
  "password": "password",
  "role": "admin"
}

and then blindly spreading the DTO into your database model.

// Avoid this pattern
const user =
  repository.create({
    ...dto,
  });

Public registration should explicitly control which fields can be written.

repository.create({
  name: dto.name,
  email: dto.email,
  passwordHash,
  role: UserRole.USER,
});

Authorization-critical values should come from trusted server-side rules.

Step 20: Add Rate Limiting to Authentication Routes

A login endpoint is an obvious target for brute-force and credential-stuffing attempts.

I would rate-limit endpoints such as:

  • login
  • registration
  • password reset requests
  • verification-code requests
  • refresh endpoints where appropriate

The exact thresholds depend on the product, infrastructure, and user behavior. I prefer combining rate limiting with monitoring rather than picking one arbitrary number and forgetting about it.

Step 21: Password Reset Needs Its Own Secure Flow

Password-reset links should not simply contain the user’s permanent authentication token.

A safer design is:

User requests password reset
        ↓
Generate random one-time token
        ↓
Store token hash + expiry
        ↓
Email raw token to user
        ↓
User submits new password + token
        ↓
Verify token
        ↓
Update password
        ↓
Invalidate token
        ↓
Revoke existing sessions

I also avoid confirming whether an email exists during the password-reset request.

A response such as:

If an account exists for this email, password reset instructions have been sent.

reveals less account information than:

No user exists with that email address.

What Happens When a User Changes Their Password?

In many applications, changing a password should invalidate existing refresh sessions.

Otherwise someone who already stole a refresh token may continue creating new access tokens even after the legitimate user changes their password.

For a basic implementation:

Password changed
      ↓
Update password hash
      ↓
Clear refresh token hash
      ↓
Require login again

For multi-device applications, I prefer a proper sessions table so individual sessions can be revoked separately.

A Better Session Model for Larger Applications

Storing one refresh-token hash on the user works for a tutorial and some simple applications, but it has a limitation: the user effectively has one refresh session.

For products where users log in from phones, tablets, and multiple browsers, I prefer a separate session table.

users
├── id
├── email
└── password_hash

sessions
├── id
├── user_id
├── refresh_token_hash
├── device
├── ip_address
├── expires_at
├── revoked_at
└── created_at

That gives you features such as:

  • log out one device
  • log out all devices
  • show active sessions
  • revoke suspicious sessions
  • track refresh-token rotation
  • implement session expiration rules

This is the kind of architectural difference I consider when moving from a tutorial implementation to a production system.

Common NestJS Authentication Mistakes

1. Storing plain-text passwords

This should never happen. Store an appropriate password hash instead.

2. Returning password hashes from APIs

Password hashes should not appear in normal user DTOs or API responses.

3. Hard-coding JWT secrets

secret: 'my-secret'

Authentication secrets belong outside the source code.

4. Creating extremely long-lived access tokens

A short-lived access token limits the useful lifetime of a stolen token.

5. Storing raw refresh tokens in the database

If you do not need the original token later, storing a verifiable hash reduces exposure.

6. Putting sensitive data inside JWT payloads

A signed JWT is not automatically secret. Do not treat its payload as encrypted private storage.

7. Trusting authorization fields from the client

Roles and permissions must come from trusted server-side state.

8. Forgetting token revocation

Think about what happens after logout, password changes, account compromise, or administrator suspension.

9. Using synchronize: true in production

Use controlled database migrations rather than allowing automatic schema synchronization to modify production data structures.

10. Logging credentials

Do not log passwords, raw refresh tokens, JWTs, authorization headers, reset tokens, or other authentication secrets.

What I Would Do Differently in a Real NestJS Project

For a small project, the implementation above is a good foundation.

For a production system with important customer data, I would usually extend it in several areas.

Use a sessions table

This gives better multi-device control than storing one refresh-token hash directly on the user.

Add email verification

I would not automatically trust ownership of an email address simply because someone entered it during registration.

Add password-reset tokens

These should be random, temporary, single-use credentials with an expiry time.

Add authentication events

I want visibility into events such as successful login, failed login, password change, account lockout, session revocation, and password-reset activity.

Add rate limiting

Especially around endpoints that can be abused for brute-force attacks or expensive operations.

Add MFA where the product warrants it

For administrator accounts, financial systems, or sensitive applications, password-only authentication may not be enough.

Use migrations from day one

Authentication tables tend to evolve. I want every database change to be reproducible across local, staging, and production environments.

Recommended Project Structure

src/
├── auth/
│   ├── dto/
│   │   ├── login.dto.ts
│   │   ├── refresh.dto.ts
│   │   └── register.dto.ts
│   ├── guards/
│   │   ├── auth.guard.ts
│   │   └── roles.guard.ts
│   ├── decorators/
│   │   └── roles.decorator.ts
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   └── auth.service.ts
│
├── users/
│   ├── entities/
│   │   └── user.entity.ts
│   ├── users.module.ts
│   └── users.service.ts
│
├── config/
│
├── app.module.ts
└── main.ts

I would not create dozens of abstractions on day one. This structure is enough to keep authentication and user responsibilities separated without making the application difficult to navigate.

NestJS Authentication Flow at a Glance

REGISTER

Request
→ DTO validation
→ Normalize email
→ Check existing account
→ Hash password
→ Save user


LOGIN

Email + Password
→ Find user
→ Verify Argon2 hash
→ Create access token
→ Create refresh token
→ Hash refresh token
→ Save refresh hash


PROTECTED REQUEST

Bearer access token
→ AuthGuard
→ Verify JWT
→ Attach user payload
→ Controller


REFRESH

Refresh token
→ Verify JWT signature
→ Load user/session
→ Verify stored hash
→ Rotate token pair
→ Store new refresh hash


LOGOUT

Authenticated user
→ Delete/revoke refresh credential

Authentication Security Checklist

  • Validate all authentication DTOs.
  • Normalize email addresses consistently.
  • Enforce unique identities at the database level.
  • Hash passwords using a suitable password-hashing algorithm.
  • Never return password hashes.
  • Keep JWT secrets outside source control.
  • Use short-lived access tokens.
  • Protect refresh credentials carefully.
  • Rotate refresh tokens where appropriate.
  • Implement logout and revocation.
  • Rate-limit authentication endpoints.
  • Do not leak whether an account exists unnecessarily.
  • Keep server roles and permissions authoritative.
  • Use controlled database migrations.
  • Never log credentials or tokens.
  • Use HTTPS in production.
  • Consider MFA for sensitive accounts.
  • Monitor suspicious authentication activity.

Final Thoughts

Building NestJS authentication is not difficult if all you need is a login endpoint and one JWT.

The real engineering work starts when you ask what happens after that:

  • What happens when the access token expires?
  • How does logout invalidate a session?
  • What happens if a refresh token is stolen?
  • How does a user log in from multiple devices?
  • What happens after a password change?
  • How do administrators revoke access?
  • Where are authentication events monitored?

That is the difference between a JWT tutorial and an authentication system you can maintain in a real application.

My preferred starting point is straightforward:

PostgreSQL
+
validated DTOs
+
Argon2 password hashes
+
short-lived JWT access tokens
+
protected refresh credentials
+
NestJS guards
+
explicit authorization
+
controlled migrations

Then I add sessions, email verification, password recovery, MFA, auditing, and more advanced authorization only when the product actually requires them.

If you are still learning NestJS architecture, read my guide to building a production-ready REST API with NestJS first. It explains the controller, service, DTO, and database structure that this authentication system builds on.

You may also find my production guide to error handling in Node.js useful, because authentication errors should follow the same consistent application-wide error strategy as the rest of your API.

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Previous guide
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.