@hazeljs/auth
JWT authentication, role-based access control, and tenant isolation — in one line of decorators.
No Passport config, no middleware soup. @UseGuards(JwtAuthGuard) on a controller and you're done.

Features
- JWT signing & verification via
JwtService (backed by jsonwebtoken)
JwtAuthGuard — @UseGuards-compatible guard that verifies Bearer tokens and attaches req.user
RoleGuard — configurable role check with inherited role hierarchy (admin satisfies manager checks automatically)
TenantGuard — tenant-level isolation; compares the tenant ID on the JWT against a URL param, header, or query string
@CurrentUser() — parameter decorator that injects the authenticated user into controller methods
@Auth() — all-in-one method decorator for JWT + optional role check without @UseGuards
Installation
npm install @hazeljs/auth
Setup
1. Register JwtModule
Configure the JWT secret once in your root module. Picks up JWT_SECRET and JWT_EXPIRES_IN env vars automatically when no options are passed.
import { HazelModule } from '@hazeljs/core';
import { JwtModule } from '@hazeljs/auth';
@HazelModule({
imports: [
JwtModule.forRoot({
secret: process.env.JWT_SECRET,
expiresIn: '1h',
issuer: 'my-app',
audience: 'my-users',
}),
],
})
export class AppModule {}
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=1h
2. Issue tokens at login
import { Service } from '@hazeljs/core';
import { JwtService } from '@hazeljs/auth';
@Service()
export class AuthLoginService {
constructor(private readonly jwt: JwtService) {}
async login(userId: string, role: string, tenantId?: string) {
const token = this.jwt.sign({
sub: userId,
role,
tenantId,
});
return { accessToken: token };
}
}
Guards
All guards are resolved from the DI container, so they can inject services.
JwtAuthGuard
Verifies the Authorization: Bearer <token> header. On success it attaches the decoded payload to req.user so downstream guards and @CurrentUser() can read it.
import { Controller, Get } from '@hazeljs/core';
import { UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, CurrentUser, AuthUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Controller('/profile')
export class ProfileController {
@Get('/')
getProfile(@CurrentUser() user: AuthUser) {
return user;
}
}
Errors thrown:
No Authorization header | 400 |
Header not in Bearer <token> format | 400 |
| Token invalid or expired | 401 |
RoleGuard
Checks the authenticated user's role against a list of allowed roles. Uses the role hierarchy so higher roles automatically satisfy lower-level checks.
import { UseGuards } from '@hazeljs/core';
import { JwtAuthGuard, RoleGuard } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard, RoleGuard('manager'))
@Controller('/reports')
export class ReportsController {}
Role hierarchy
The default hierarchy is superadmin → admin → manager → user.
superadmin
└─ admin
└─ manager
└─ user
So RoleGuard('user') passes for every role, and RoleGuard('admin') only passes for admin and superadmin.
@UseGuards(JwtAuthGuard, RoleGuard('admin'))
@UseGuards(JwtAuthGuard, RoleGuard('user'))
@UseGuards(JwtAuthGuard, RoleGuard('admin', 'moderator'))
Custom hierarchy
import { RoleGuard, RoleHierarchy } from '@hazeljs/auth';
const hierarchy = new RoleHierarchy({
owner: ['editor'],
editor: ['viewer'],
viewer: [],
});
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy }))
Disable hierarchy
@UseGuards(JwtAuthGuard, RoleGuard('admin', { hierarchy: {} }))
Errors thrown:
No req.user (guard order wrong) | 401 |
| User role not in allowed set | 403 |
TenantGuard
Enforces tenant-level isolation. Compares req.user.tenantId (from the JWT) against the tenant ID found in the request.
Requires JwtAuthGuard to run first so req.user is populated.
URL param (default)
import { JwtAuthGuard, TenantGuard } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard, TenantGuard())
@Controller('/orgs/:tenantId/invoices')
export class InvoicesController {}
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
@Controller('/invoices')
export class InvoicesController {}
Query string
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'query', key: 'org' }))
@Controller('/invoices')
export class InvoicesController {}
Bypass for privileged roles
Superadmins often need to manage any tenant. Use bypassRoles to skip the check for them:
@UseGuards(
JwtAuthGuard,
TenantGuard({ bypassRoles: ['superadmin'] })
)
@Controller('/orgs/:tenantId/settings')
export class OrgSettingsController {}
Custom user field
@UseGuards(JwtAuthGuard, TenantGuard({ userField: 'orgId' }))
All options:
source | 'param' | 'header' | 'query' | 'param' | Where to read the tenant ID from the request |
key | string | 'tenantId' | Param name / header name / query key |
userField | string | 'tenantId' | Field on req.user holding the user's tenant |
bypassRoles | string[] | [] | Roles that skip the check entirely |
Errors thrown:
No req.user | 401 |
req.user has no tenant field | 403 |
| Tenant ID absent from request | 400 |
| Tenant IDs do not match | 403 |
Database-level tenant isolation
TenantGuard blocks cross-tenant HTTP requests, but that alone isn't enough — a bug in service code could still return another tenant's rows. TenantContext closes that gap by enforcing isolation at the query level using Node.js AsyncLocalStorage.
After TenantGuard validates the request it calls TenantContext.enterWith(tenantId), which seeds the tenant ID into the current async execution chain. Every service and repository downstream can then call tenantCtx.requireId() to get the current tenant without it being passed through every function signature.
import { Service } from '@hazeljs/core';
import { TenantContext } from '@hazeljs/auth';
@Service()
export class OrdersRepository {
constructor(private readonly tenantCtx: TenantContext) {}
findAll() {
const tenantId = this.tenantCtx.requireId();
return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
}
findById(id: string) {
const tenantId = this.tenantCtx.requireId();
return db.query(
'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2',
[id, tenantId]
);
}
}
The route setup:
@UseGuards(JwtAuthGuard, TenantGuard())
@Controller('/orgs/:tenantId/orders')
export class OrdersController {
constructor(private readonly repo: OrdersRepository) {}
@Get('/')
list() {
return this.repo.findAll();
}
}
The two layers together:
TenantGuard | Rejects requests where req.user.tenantId !== :tenantId | Unauthenticated cross-tenant requests |
TenantContext | Scopes every DB query via AsyncLocalStorage | Bugs, missing guard on a route, IDOR attempts |
For background jobs or tests, you can run code in a specific tenant context explicitly:
await TenantContext.run('acme', async () => {
await ordersService.processPendingOrders();
});
requireId() throws with a 500 if called outside any tenant context (guard missing), giving you a clear error instead of silently querying all tenants.
Combining guards
Guards run left-to-right. Always put JwtAuthGuard first.
@UseGuards(JwtAuthGuard, RoleGuard('manager'), TenantGuard())
@Controller('/orgs/:tenantId/orders')
export class OrdersController {
@Get('/')
listOrders(@CurrentUser() user: AuthUser) {
return this.ordersService.findAll(user.tenantId!);
}
@UseGuards(RoleGuard('admin'))
@Delete('/:id')
deleteOrder(@Param('id') id: string) {
return this.ordersService.remove(id);
}
}
@CurrentUser() decorator
Injects the authenticated user (or a specific field from it) directly into the controller parameter.
import { CurrentUser, AuthUser } from '@hazeljs/auth';
@UseGuards(JwtAuthGuard)
@Get('/me')
getMe(@CurrentUser() user: AuthUser) {
return user;
}
@Get('/role')
getRole(@CurrentUser('role') role: string) {
return { role };
}
@Get('/tenant')
getTenant(@CurrentUser('tenantId') tenantId: string) {
return { tenantId };
}
@Auth() decorator (method-level shorthand)
A lower-level alternative that wraps the handler directly instead of using the @UseGuards metadata system. Useful for one-off routes or when you prefer explicit colocation.
import { Auth } from '@hazeljs/auth';
@Controller('/admin')
export class AdminController {
@Auth()
@Get('/dashboard')
getDashboard() { ... }
@Auth({ roles: ['admin'] })
@Delete('/user/:id')
deleteUser(@Param('id') id: string) { ... }
}
Note: @Auth() does not use the role hierarchy. Use @UseGuards(JwtAuthGuard, RoleGuard('admin')) when hierarchy matters.
JwtService API
import { JwtService } from '@hazeljs/auth';
const token = jwtService.sign({ sub: userId, role: 'admin', tenantId: 'acme' });
const token = jwtService.sign({ sub: userId }, { expiresIn: '15m' });
const payload = jwtService.verify(token);
payload.sub
payload.role
payload.tenantId
const payload = jwtService.decode(token);
AuthService API
AuthService wraps JwtService and returns a typed AuthUser object:
interface AuthUser {
id: string;
username?: string;
role: string;
[key: string]: unknown;
}
const user = await authService.verifyToken(token);
Identity normalization for OAuth/SAML callbacks
When upstream identity providers emit non-uniform claims (sub, externalId, tenant_id, etc.),
normalize them before issuing app tokens:
const normalized = authService.normalizeIdentity({
externalId: samlResult.nameId,
email: samlResult.user.email,
role: 'user',
tenant_id: 'acme',
});
normalizeIdentity() maps these shapes to a consistent AuthUser used by JwtAuthGuard, RoleGuard, and TenantGuard.
RoleHierarchy API
import { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from '@hazeljs/auth';
const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
h.satisfies('superadmin', 'user')
h.satisfies('manager', 'admin')
h.resolve('admin')
Custom guards
Implement CanActivate from @hazeljs/core for fully custom logic:
import { Injectable, CanActivate, ExecutionContext } from '@hazeljs/core';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest() as { headers: Record<string, string> };
return req.headers['x-api-key'] === process.env.API_KEY;
}
}
The ExecutionContext also exposes the fully parsed RequestContext (params, query, headers, body, user):
canActivate(context: ExecutionContext): boolean {
const ctx = context.switchToHttp().getContext();
const orgId = ctx.params['orgId'];
const user = ctx.user;
}
Environment variables
JWT_SECRET | (required) | Secret used to sign and verify tokens |
JWT_EXPIRES_IN | 1h | Default token lifetime |
JWT_ISSUER | — | Optional iss claim |
JWT_AUDIENCE | — | Optional aud claim |
Links