After the JWT is verified and the user's authenticity is established, the next question arrives: what is this user allowed to do? This is where authorization begins. The most common approach is RBAC.
What RBAC is and what the problem is without it
RBAC stands for Role-Based Access Control. The idea is simple: each user is assigned a role (customer, admin, seller), and access to specific actions depends on the role, not on the specific person.
Without RBAC, developers often write checks right inside the handlers:
async createOrder(userId: string) {
const user = await this.users.findById(userId);
if (user.type !== 'customer') throw new Error('Forbidden');
// ...
}
There end up being many such checks, scattered across the code, and it's easy to miss something. RBAC moves this logic to one level — into a Guard that runs before any handler.
Where roles come from: JwtStrategy.validate
The user's roles arrive in the JWT token. In Keycloak they live in the realm_access.roles field:
{
"sub": "user-42",
"realm_access": {
"roles": ["customer"]
}
}
In a standard OAuth2 server the roles may be in the scope field as a space-separated string.
The only place where we parse the token and extract the roles is the validate method in JwtStrategy. It returns a Principal object, which NestJS puts into request.user and makes available in all guards and controllers:
export interface JwtClaims {
sub: string;
realm_access?: { roles: string[] };
scope?: string;
}
export interface Principal {
sub: string;
roles: string[];
}
function extractRoles(claims: JwtClaims): string[] {
if (claims.realm_access?.roles) {
return claims.realm_access.roles;
}
return claims.scope ? claims.scope.split(' ') : [];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000,
}),
});
}
validate(claims: JwtClaims): Principal {
return { sub: claims.sub, roles: extractRoles(claims) };
}
}
An important detail: unlike Spring Security, there is no ROLE_ prefix here. Roles are compared as ordinary strings — 'customer', not 'ROLE_CUSTOMER'.
RolesGuard and the @Roles decorator
A Guard in NestJS is a class that decides: let the request through or return an error. RolesGuard reads which roles the endpoint requires (through the decorator metadata) and compares them with what the user has:
export const Roles = Reflector.createDecorator<string[]>();
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get(Roles, ctx.getHandler());
if (!required) {
throw new ForbiddenException();
}
const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
if (!required.some((r) => user.roles.includes(r))) {
throw new ForbiddenException();
}
return true;
}
}
Notice: if a method has no @Roles(...) and no @Public() marker, the guard throws ForbiddenException. This is a safeguard against an accidentally unprotected endpoint — silently letting everyone through would be more dangerous.
For public endpoints (for example, GET /products) you need a separate @Public() marker:
export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);
The order of guard registration matters
Guards are registered globally through APP_GUARD. The order is mandatory: first JwtAuthGuard checks the token and returns 401 if it's missing, and only then RolesGuard checks the roles and returns 403:
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 401: no token
{ provide: APP_GUARD, useClass: RolesGuard }, // 403: no required role
],
})
export class AppModule {}
If you swap them, RolesGuard gets request.user = undefined and breaks.
How to apply @Roles in a controller
The decorator is put on each method. You can specify several roles — the guard lets the request through if the user has at least one of them:
@Controller('orders')
export class OrderController {
constructor(private readonly dispatcher: UseCaseDispatcher) {}
@Post()
@Roles(['customer'])
create(
@Body() body: CreateOrderRequest,
@CurrentUser() principal: Principal,
): Promise<OrderResponse> {
return this.dispatcher.dispatch(new CreateOrderCommand(body, principal));
}
@Get(':id')
@Roles(['customer', 'admin'])
findById(
@Param('id') id: string,
@CurrentUser() principal: Principal,
): Promise<OrderResponse> {
return this.dispatcher.dispatch(new GetOrderByIdQuery(id, principal));
}
}
@CurrentUser() is a helper decorator that pulls out request.user:
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): Principal =>
ctx.switchToHttp().getRequest<{ user: Principal }>().user,
);
So that the role strings don't scatter across the whole codebase, it's convenient to move them into constants:
export const ROLES = {
CUSTOMER: 'customer',
SELLER: 'seller',
ADMIN: 'admin',
SYSTEM: 'system',
} as const;
How many roles you need and when to stop
A typical minimal role catalog for a product application:
| Role | Who | What they do |
|---|---|---|
customer | End user | Creates and reads their own orders |
seller | Seller | Manages their own products, sees orders for their items |
admin | Internal operator | Full access, every action goes into the log |
system | Another service | Inter-service calls via Client Credentials |
When a team starts adding customer-premium, partner-seller, junior-admin — this is usually a sign that RBAC is trying to solve a task beyond its power. Let's go through the common cases:
- "VIP customers have different limits" — this isn't a new role but a
customer.tierattribute. It's checked in the command handler by the field value. - "B2B customers work with wholesale products" — most likely this is a separate context with its own model, not a new role.
- "A manager sees a customer's data but can't delete it" — this is a permissions system, not RBAC.
The fewer roles, the easier they are to maintain. When there are many roles, they start to overlap and contradict each other.
RBAC doesn't answer every question — and that's fine
RBAC handles one specific question well: "can the customer role call this endpoint at all?"
But it doesn't solve the question: "can this specific customer cancel exactly this order?" That's already ABAC (Attribute-Based Access Control) — a check at the level of a specific resource.
// Controller: RBAC — endpoint-level
@Post(':id/cancel')
@Roles(['customer', 'admin'])
cancel(@Param('id') id: string, @CurrentUser() principal: Principal) {
return this.dispatcher.dispatch(new CancelOrderCommand(id, principal));
}
// Handler: ABAC — resource-level
async execute(cmd: CancelOrderCommand): Promise<void> {
const order = await this.orders.byId(cmd.orderId);
if (!cmd.principal.roles.includes('admin') && order.customerId !== cmd.principal.sub) {
throw new ForbiddenError(cmd.orderId);
}
order.cancel();
await this.orders.save(order);
}
Both layers are needed: the @Roles on the outside does not replace the owner check inside the handler.
In short
- RBAC is access control by role, implemented through
RolesGuardand@Rolesin NestJS. - Roles are extracted from the JWT once in
JwtStrategy.validateand end up inPrincipal.roles. - Keycloak puts roles in
realm_access.roles, standard OAuth2 — inscope. - Guards are registered through
APP_GUARD: authentication first (401), then authorization (403). - An endpoint without
@Rolesand without@Public()— the guard blocks every request, even an authenticated one. - The minimal role catalog:
customer,seller,admin,system. More roles is a sign of a design problem. - RBAC answers the question "can the role call the endpoint", ABAC — "can this user work with this resource".
What to read next
- JWT validation in NestJS — how to set up
JwtStrategywith key verification. - ABAC: resource ownership — the next layer after RBAC.
- Audit of admin commands — why the
adminrole always requires an action log. - Service-to-service — Client Credentials and the
systemrole.