← Back to Blog
Implementing Secure JWT Authentication in NestJS Applications

Authentication is the foundation of any secure application. During my work on the EMA project at Servant, I implemented a comprehensive JWT-based authentication system with role-based access control for four distinct user types: admins, coordinators, advocates, and mothers.

Why JWT?

JSON Web Tokens provide a stateless authentication mechanism that scales well and works seamlessly across different services. Unlike session-based auth, JWTs don't require server-side session storage, making them ideal for modern distributed systems.

The Authentication Flow

Here's how we structured the authentication service in NestJS:

// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';
 
@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}
 
  async validateUser(email: string, password: string): Promise<any> {
    const user = await this.usersService.findByEmail(email);
 
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }
 
    const isPasswordValid = await bcrypt.compare(
      password,
      user.passwordHash
    );
 
    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }
 
    const { passwordHash, ...result } = user;
    return result;
  }
 
  async login(user: any) {
    const payload = {
      email: user.email,
      sub: user.id,
      role: user.role
    };
 
    return {
      access_token: this.jwtService.sign(payload),
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        role: user.role
      }
    };
  }
 
  async refreshToken(userId: string) {
    const user = await this.usersService.findById(userId);
 
    if (!user) {
      throw new UnauthorizedException('User not found');
    }
 
    return this.login(user);
  }
}

Role-Based Access Control

The real power comes from implementing role-based guards:

// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../shared/enums/user-role.enum';
 
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
 
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
      'roles',
      [context.getHandler(), context.getClass()]
    );
 
    if (!requiredRoles) {
      return true;
    }
 
    const { user } = context.switchToHttp().getRequest();
 
    return requiredRoles.some((role) => user.role === role);
  }
}
 
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../shared/enums/user-role.enum';
 
export const Roles = (...roles: UserRole[]) => SetMetadata('roles', roles);

Now we can protect routes based on user roles:

// referrals.controller.ts
@Controller('referrals')
@UseGuards(JwtAuthGuard, RolesGuard)
export class ReferralsController {
  constructor(private readonly referralsService: ReferralsService) {}
 
  @Post()
  @Roles(UserRole.COORDINATOR, UserRole.ADVOCATE)
  async createReferral(@Body() createReferralDto: CreateReferralDto) {
    return this.referralsService.create(createReferralDto);
  }
 
  @Get()
  @Roles(UserRole.ADMIN, UserRole.COORDINATOR)
  async getAllReferrals() {
    return this.referralsService.findAll();
  }
 
  @Get('my-referrals')
  @Roles(UserRole.ADVOCATE)
  async getMyReferrals(@Request() req) {
    return this.referralsService.findByAdvocate(req.user.id);
  }
}

JWT Strategy Implementation

The Passport strategy validates tokens on each request:

// jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
 
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }
 
  async validate(payload: any) {
    return {
      id: payload.sub,
      email: payload.email,
      role: payload.role
    };
  }
}

Security Best Practices

Based on 27 years of experience, here are critical security considerations:

  1. Never store passwords in plain text: Always use bcrypt or similar
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
  1. Use environment variables for secrets:
// .env
JWT_SECRET=your-super-secret-key-change-this
JWT_EXPIRATION=1h
  1. Implement token refresh mechanisms: Short-lived access tokens with refresh tokens

  2. Add rate limiting: Prevent brute force attacks

import { ThrottlerModule } from '@nestjs/throttler';
 
@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60,
      limit: 10,
    }),
  ],
})
export class AppModule {}
  1. Validate all inputs: Use class-validator
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
 
export class LoginDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;
 
  @IsNotEmpty()
  @MinLength(8)
  password: string;
}

Frontend Integration

On the Next.js side, we used the token for authenticated API requests:

// lib/api-client.ts
export class ApiClient {
  private baseUrl: string;
  private token: string | null = null;
 
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }
 
  setToken(token: string) {
    this.token = token;
  }
 
  async request(endpoint: string, options: RequestInit = {}) {
    const headers = {
      'Content-Type': 'application/json',
      ...(this.token && { Authorization: `Bearer ${this.token}` }),
      ...options.headers,
    };
 
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers,
    });
 
    if (!response.ok) {
      throw new Error(`API error: ${response.statusText}`);
    }
 
    return response.json();
  }
}

Conclusion

Implementing secure authentication requires attention to detail and following best practices. JWT provides a solid foundation, but security is about the entire system - from password hashing to rate limiting to proper token management.

The investment in a robust authentication system pays dividends in security, scalability, and peace of mind.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.