How We Build Secure Role-Based Web Apps with NestJS
Introduction to Role-Based Access Control
For most business applications, a simple "logged in" or "not logged in" authorization model isn't sufficient. Different users need different levels of access based on their responsibilities, department, or relationship to the data. This is where Role-Based Access Control (RBAC) comes in.
When building applications at LaPage Digital, we've developed a comprehensive approach to RBAC using NestJS as our backend framework. NestJS, with its Angular-inspired architecture, provides a structured approach to building server-side applications, making it ideal for implementing robust security patterns.
Core Architecture Components
1. Authentication Layer
Before we can authorize users based on roles, we need to authenticate them. We typically use JWT (JSON Web Tokens) for stateless authentication with NestJS's built-in JWT support:
import { Injectable } 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 {
const user = await this.usersService.findByEmail(email);
if (user && await bcrypt.compare(password, user.password)) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: any) {
const payload = {
email: user.email,
sub: user.id,
roles: user.roles, // Include roles in the payload
};
return {
access_token: this.jwtService.sign(payload),
};
}
}
2. Role Entity Design
We design our user and role entities to support a many-to-many relationship, allowing users to have multiple roles:
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@ManyToMany(() => Role, role => role.users)
@JoinTable()
roles: Role[];
}
// role.entity.ts
@Entity()
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
name: string;
@Column({ nullable: true })
description: string;
@ManyToMany(() => User, user => user.roles)
users: User[];
@ManyToMany(() => Permission, permission => permission.roles)
@JoinTable()
permissions: Permission[];
}
3. Role-Based Guards
The heart of our RBAC system is the NestJS guard that checks user roles:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private jwtService: JwtService,
) {}
async canActivate(context: ExecutionContext): Promise {
const requiredRoles = this.reflector.get(
'roles',
context.getHandler(),
);
if (!requiredRoles) {
return true; // No roles required, allow access
}
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
return false;
}
try {
const payload = await this.jwtService.verifyAsync(token);
request['user'] = payload;
// Check if user has any of the required roles
return requiredRoles.some((role) =>
payload.roles?.includes(role)
);
} catch {
return false;
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
4. Custom Decorators for Roles
To make role requirements clear and maintainable, we create a custom decorator:
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
5. Using the Role-Based Authorization
With these components in place, adding role requirements to endpoints becomes straightforward:
@Controller('products')
export class ProductsController {
constructor(private productsService: ProductsService) {}
@Get()
getAllProducts() {
return this.productsService.findAll();
}
@Post()
@Roles('admin', 'product-manager')
@UseGuards(JwtAuthGuard, RolesGuard)
createProduct(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Delete(':id')
@Roles('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
removeProduct(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
Advanced Role-Based Features
Permission-Based Access Control
For more granular control, we often implement a permission system on top of roles. Each role has a set of permissions, and we check these permissions for specific actions:
@Entity()
export class Permission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
name: string; // e.g., "create:products", "read:orders"
@Column({ nullable: true })
description: string;
@ManyToMany(() => Role, role => role.permissions)
roles: Role[];
}
Role-Based Frontend Rendering
On the frontend, we use the JWT to determine which UI elements should be visible to the user:
// React component example
const ProductActions = ({ product }) => {
const { user } = useAuth();
// Check if user has admin role
const isAdmin = user?.roles?.includes('admin');
// Check if user has edit permission
const canEditProduct = user?.permissions?.includes('edit:products');
return (
{canEditProduct && (
)}
{isAdmin && (
)}
);
};
Security Best Practices
When implementing RBAC with NestJS, we follow these security best practices:
- Least Privilege Principle: Users should only have the minimum permissions necessary to perform their jobs.
- Server-Side Validation: Never trust the client. Always validate permissions on the server, even if the UI doesn't show certain actions.
- JWT Expiration: Set reasonable expiration times for JWTs and implement refresh token flows.
- Role Segregation: Separate administrative functions from regular user functions.
- Audit Logging: Log all permission-related actions for security audits.
Conclusion
Building secure role-based web applications with NestJS requires careful planning and implementation, but the framework provides excellent tools to make this task more manageable. By following the patterns outlined above, we've successfully implemented secure RBAC in numerous applications for our clients.
The combination of TypeScript's strong typing, NestJS's structured architecture, and JWT's stateless authentication creates a robust foundation for building secure, role-based applications that scale with your business needs.
Huy Lan
Founder & Technical Lead
Full-stack developer and infrastructure expert with a strong background in building scalable digital products and managing servers. Huy Lan founded LaPage Digital to help businesses automate, scale, and operate more efficiently through modern web technologies and infrastructure.
Subscribe to Our Newsletter
Get the latest articles, tutorials, and updates on web development and hosting directly to your inbox.