LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
NestJSIntermediate~2.5 hours

JWT Authentication Service

Build register, login and profile endpoints with hashed passwords, JWT access tokens, a global auth guard and role-based admin routes.

GuardsJWTbcryptCustom DecoratorsConfiguration

Overview

Almost every real application needs accounts: sign up, log in, see "my" data, and keep ordinary users out of admin screens. This project builds that complete flow as a standalone NestJS service, so you can reuse it as the starting point for any future API.

Passwords are hashed with bcrypt, successful logins receive a signed JWT, and a guard registered with APP_GUARD protects every route by default. Routes that must stay open, like login and register, opt out with a custom @Public() decorator, and a second guard enforces @Roles('admin'). Users are kept in memory to keep the focus on authentication — you can plug in the TypeORM setup from the previous project later.

What You'll Build
  • POST /auth/register and POST /auth/login endpoints with validated DTOs.
  • A UsersService that stores bcrypt password hashes, never plain-text passwords.
  • JWT access tokens signed with a secret loaded from .env through @nestjs/config.
  • A global AuthGuard plus a @Public() decorator for open routes.
  • A @CurrentUser() parameter decorator, a @Roles() decorator and a RolesGuard.
  • A protected GET /auth/profile route and an admin-only GET /users route.

Prerequisites

  • Providers and dependency injection — lesson 5.
  • DTOs and validation — lesson 6.
  • Guards, JWTs, SetMetadata and Reflector — lesson 9.
  • Configuration with @nestjs/config — lesson 11.

Project Structure

auth-service/
├── .env
└── src/
├── auth/
│ ├── decorators/
│ │ ├── current-user.decorator.ts
│ │ ├── public.decorator.ts
│ │ └── roles.decorator.ts
│ ├── dto/
│ │ ├── login.dto.ts
│ │ └── register.dto.ts
│ ├── guards/
│ │ ├── auth.guard.ts
│ │ └── roles.guard.ts
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ └── auth.service.ts
├── users/
│ ├── users.controller.ts
│ ├── users.module.ts
│ └── users.service.ts
├── app.module.ts
└── main.ts

UsersModule knows how to store and find users; it knows nothing about tokens. AuthModule imports it and adds everything about proving identity: hashing checks, JWTs, guards and decorators. Keeping these apart means you could replace JWTs with sessions without touching user storage.

Step 1: Set Up the Project & Configuration

nest new auth-service
cd auth-service
npm install @nestjs/config @nestjs/jwt bcrypt class-validator class-transformer
npm install -D @types/bcrypt
npm install-scripts approve bcrypt # allow its native build step (npm 12+)
npm rebuild bcrypt
.env
JWT_SECRET=replace-this-with-a-long-random-string-of-at-least-32-chars
JWT_EXPIRES_IN=1h
Keep .env Out of Git

Add .env to .gitignore before your first commit. Anyone holding your JWT secret can forge tokens for any user, including admins.

Step 2: Build the UsersService

The service stores a passwordHash, never the password itself. toPublic() strips the hash before anything leaves the service, so no controller can accidentally return it. For demo purposes the very first account registered becomes an admin.

src/users/users.service.ts
import { ConflictException, Injectable } from '@nestjs/common';
export type Role = 'user' | 'admin';
export interface User {
id: number;
name: string;
email: string;
passwordHash: string;
role: Role;
}
export type PublicUser = Omit<User, 'passwordHash'>;
@Injectable()
export class UsersService {
private users: User[] = [];
private nextId = 1;
findByEmail(email: string): User | undefined {
return this.users.find((u) => u.email === email.toLowerCase());
}
findById(id: number): User | undefined {
return this.users.find((u) => u.id === id);
}
create(name: string, email: string, passwordHash: string): User {
if (this.findByEmail(email)) {
throw new ConflictException('An account with this email already exists');
}
const user: User = {
id: this.nextId++,
name,
email: email.toLowerCase(),
passwordHash,
role: this.users.length === 0 ? 'admin' : 'user', // first user is the admin (demo only)
};
this.users.push(user);
return user;
}
findAll(): PublicUser[] {
return this.users.map((u) => this.toPublic(u));
}
toPublic(user: User): PublicUser {
const { passwordHash: _hash, ...rest } = user;
return rest;
}
}
src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // AuthModule needs it
})
export class UsersModule {}

Step 3: Register & Log In with AuthService

src/auth/dto/register.dto.ts
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
@MinLength(2)
@MaxLength(50)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}
src/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}

login() returns the same "Invalid email or password" message whether the email is unknown or the password is wrong, so attackers cannot use the login form to discover which emails are registered. The token payload carries only the user id (sub), email and role.

src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User, UsersService } from '../users/users.service.js';
import { LoginDto } from './dto/login.dto.js';
import { RegisterDto } from './dto/register.dto.js';
export interface JwtPayload {
sub: number;
email: string;
role: User['role'];
}
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async register(dto: RegisterDto) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = this.usersService.create(dto.name, dto.email, passwordHash);
return this.issueToken(user);
}
async login(dto: LoginDto) {
const user = this.usersService.findByEmail(dto.email);
const valid = user ? await bcrypt.compare(dto.password, user.passwordHash) : false;
if (!user || !valid) {
throw new UnauthorizedException('Invalid email or password');
}
return this.issueToken(user);
}
private async issueToken(user: User) {
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
return {
access_token: await this.jwtService.signAsync(payload),
user: this.usersService.toPublic(user),
};
}
}

Step 4: Decorators & Guards

@Public() and @Roles() attach metadata to a route with SetMetadata; the guards read it back with Reflector. @CurrentUser() is a parameter decorator that returns the payload the AuthGuard attached to the request, so handlers never touch the raw request object.

src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../users/users.service.js';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../auth.service.js';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload => ctx.switchToHttp().getRequest().user,
);
src/auth/guards/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { JwtPayload } from '../auth.service.js';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator.js';
@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 & { user?: JwtPayload }>();
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Missing bearer token');
}
try {
request.user = await this.jwtService.verifyAsync<JwtPayload>(token);
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
return true;
}
}
src/auth/guards/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../../users/users.service.js';
import { JwtPayload } from '../auth.service.js';
import { ROLES_KEY } from '../decorators/roles.decorator.js';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const user: JwtPayload | undefined = context.switchToHttp().getRequest().user;
return !!user && required.includes(user.role);
}
}

Step 5: Wire Up the Controllers & Modules

Both guards are registered globally with APP_GUARD, and the order of the providers array matters: AuthGuard must run first so request.user exists by the time RolesGuard checks it. JwtModule.registerAsync() reads the secret from ConfigService, guaranteeing the .env file is loaded first.

src/auth/auth.controller.ts
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuthService } from './auth.service.js';
import type { JwtPayload } from './auth.service.js';
import { CurrentUser } from './decorators/current-user.decorator.js';
import { Public } from './decorators/public.decorator.js';
import { LoginDto } from './dto/login.dto.js';
import { RegisterDto } from './dto/register.dto.js';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService,
) {}
@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('profile')
profile(@CurrentUser() current: JwtPayload) {
const user = this.usersService.findById(current.sub);
return user ? this.usersService.toPublic(user) : current;
}
}
src/users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator.js';
import { UsersService } from './users.service.js';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Roles('admin')
@Get()
findAll() {
return this.usersService.findAll();
}
}
src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module.js';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
import { AuthGuard } from './guards/auth.guard.js';
import { RolesGuard } from './guards/roles.guard.js';
@Module({
imports: [
UsersModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '1h') },
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
{ provide: APP_GUARD, useClass: AuthGuard }, // runs first: who are you?
{ provide: APP_GUARD, useClass: RolesGuard }, // runs second: are you allowed?
],
})
export class AuthModule {}

Complete Code

Steps 2–5 contain every feature file in full. The root module loads configuration globally and imports both feature modules, and main.ts enables validation:

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module.js';
import { UsersModule } from './users/users.module.js';
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), UsersModule, AuthModule],
})
export class AppModule {}
src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

Sample Run

Testing with curl

Click Run to see what this code prints.

Extend This Project

  • Store users in a database with TypeORM (reuse the Blog API setup) and add a unique index on email.
  • Add refresh tokens: a long-lived token stored in the database that can be exchanged for a new access token and revoked on logout.
  • Add rate limiting to /auth/login with @nestjs/throttler to slow down password-guessing attacks.
  • Add PATCH /users/:id/role for admins to promote or demote other users.
  • Write e2e tests that register, log in and call /auth/profile with and without the token.

Summary

You built a reusable authentication service: hashed passwords, JWT login, protection for every route by default, simple public opt-outs, and role-based authorization with custom decorators. This "secure by default" structure is the one to copy into every NestJS API you build from now on.