Day#5: Continuation: Securing Routes with JWT and Role-Based Access Control
TL;DR: Continuing development on the backend platform — this time focusing on securing routes with JWT, implementing @Roles
decorators, handling role propagation from the database, and creating a custom @Public
annotation for open endpoints.
🔐 Global Auth with Fine-Grained Access Control
After getting the core system working, the next challenge was enforcing proper access control across the API. This involved:
- Setting up global guards for JWT authentication
- Enabling role-based access control (RBAC) via a
@Roles()
decorator - Safely exposing certain endpoints using a custom
@Public
decorator
🧱 Strategy: JWT + Roles + Global Guards
The approach includes two guards used together:
JwtAuthGuard
: checks for a valid tokenRolesGuard
: ensures the authenticated user has the required roles for a route
And both are added globally via:
app.useGlobalGuards(
app.get(JwtAuthGuard),
app.get(RolesGuard)
);
This way, developers don’t need to manually add @UseGuards()
to every controller — only @Roles()
where needed.
🛡️ Role Propagation from the Database
User roles are not hardcoded. Instead, they are pulled from the database at login and attached to the JWT payload:
async validateUser(email: string, password: string) {
const partner = await this.partnerService.findByEmailWithRoles(email);
if (!partner) throw new UnauthorizedException();
const roles = partner.systemRoles?.map(r => r.name) ?? [];
const valid = await bcrypt.compare(password, partner.passwordHash);
if (!valid) throw new UnauthorizedException();
const { passwordHash, ...rest } = partner;
return { ...rest, roles };
}
When the user logs in, the JWT token contains their id
, email
, and roles
:
async login(user: any) {
const payload = {
sub: user.id,
email: user.email,
roles: user.roles,
};
return {
access_token: this.jwtService.sign(payload),
};
}
This structure is then used by the JwtStrategy
to restore context:
async validate(payload: any) {
return {
id: payload.sub,
email: payload.email,
roles: payload.roles,
};
}
🏷️ The @Public()
Decorator
To mark an endpoint as open (not requiring authentication), a custom decorator is introduced:
export const Public = () => SetMetadata('isPublic', true);
In the guards, we check for this flag:
const isPublic = this.reflector.get<boolean>('isPublic', context.getHandler());
if (isPublic) return true;
This enables simple public routes:
@Public()
@Get('ping')
ping() {
return { ping: 'pong' };
}
🔒 Using @Roles()
with Guards
Routes that require authorization can now be protected like this:
@Roles('admin', 'roles_r')
@Get('secure')
getSecureData() {
return { secret: 'classified' };
}
The RolesGuard
fetches the required roles from metadata and checks if the user has at least one of them.
🧪 Testing with Swagger and Postman
Swagger is configured with @ApiBearerAuth()
globally. During testing:
- Call
POST /auth/login
with valid credentials. - Copy the returned token.
- In Swagger UI, click Authorize, and enter:
Bearer <token>
The system now authenticates and authorizes users correctly.
🧠 Lessons Learned
- Relying on global guards reduces repetitive annotations
- You must explicitly propagate roles at login — they won’t appear by magic
@Public()
gives you a neat way to declare unsecured endpoints- Swagger and Postman together make testing JWT-secured APIs manageable
Next up: implementing hierarchical access control and scoping data visibility across nested entities.