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.
POST /auth/registerandPOST /auth/loginendpoints with validated DTOs.- A
UsersServicethat stores bcrypt password hashes, never plain-text passwords. - JWT access tokens signed with a secret loaded from
.envthrough@nestjs/config. - A global
AuthGuardplus a@Public()decorator for open routes. - A
@CurrentUser()parameter decorator, a@Roles()decorator and aRolesGuard. - A protected
GET /auth/profileroute and an admin-onlyGET /usersroute.
Prerequisites
- Providers and dependency injection — lesson 5.
- DTOs and validation — lesson 6.
- Guards, JWTs,
SetMetadataandReflector— 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.tsUsersModule 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-servicecd auth-servicenpm install @nestjs/config @nestjs/jwt bcrypt class-validator class-transformernpm install -D @types/bcryptnpm install-scripts approve bcrypt # allow its native build step (npm 12+)npm rebuild bcryptJWT_SECRET=replace-this-with-a-long-random-string-of-at-least-32-charsJWT_EXPIRES_IN=1hAdd .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.
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; }}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
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;}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.
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.
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);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);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,);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; }}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.
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; }}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(); }}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:
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 {}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
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/loginwith@nestjs/throttlerto slow down password-guessing attacks. - Add
PATCH /users/:id/rolefor admins to promote or demote other users. - Write e2e tests that register, log in and call
/auth/profilewith 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.