
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
@hazeljs/auth
Advanced tools
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.
JwtService (backed by jsonwebtoken)JwtAuthGuard — @UseGuards-compatible guard that verifies Bearer tokens and attaches req.userRoleGuard — 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 @UseGuardsnpm install @hazeljs/auth
JwtModuleConfigure 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, // or set JWT_SECRET env var
expiresIn: '1h', // or set JWT_EXPIRES_IN env var
issuer: 'my-app', // optional
audience: 'my-users', // optional
}),
],
})
export class AppModule {}
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=1h
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, // include for TenantGuard
});
return { accessToken: token };
}
}
All guards are resolved from the DI container, so they can inject services.
JwtAuthGuardVerifies 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) // protects every route in this controller
@Controller('/profile')
export class ProfileController {
@Get('/')
getProfile(@CurrentUser() user: AuthUser) {
return user;
}
}
Errors thrown:
| Condition | Status |
|---|---|
No Authorization header | 400 |
Header not in Bearer <token> format | 400 |
| Token invalid or expired | 401 |
RoleGuardChecks 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')) // manager, admin, superadmin can access
@Controller('/reports')
export class ReportsController {}
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.
// Only superadmin and admin can call this:
@UseGuards(JwtAuthGuard, RoleGuard('admin'))
// Everyone authenticated can call this:
@UseGuards(JwtAuthGuard, RoleGuard('user'))
// Either role (admin OR moderator, each with their inherited roles):
@UseGuards(JwtAuthGuard, RoleGuard('admin', 'moderator'))
import { RoleGuard, RoleHierarchy } from '@hazeljs/auth';
const hierarchy = new RoleHierarchy({
owner: ['editor'],
editor: ['viewer'],
viewer: [],
});
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy }))
// owner and editor pass; viewer does not
@UseGuards(JwtAuthGuard, RoleGuard('admin', { hierarchy: {} }))
// Only exact 'admin' role passes — no inheritance
Errors thrown:
| Condition | Status |
|---|---|
No req.user (guard order wrong) | 401 |
| User role not in allowed set | 403 |
TenantGuardEnforces 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.
import { JwtAuthGuard, TenantGuard } from '@hazeljs/auth';
// Route: GET /orgs/:tenantId/invoices
@UseGuards(JwtAuthGuard, TenantGuard())
@Controller('/orgs/:tenantId/invoices')
export class InvoicesController {}
// Client sends: X-Org-ID: acme
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'header', key: 'x-org-id' }))
@Controller('/invoices')
export class InvoicesController {}
// Client sends: GET /invoices?org=acme
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'query', key: 'org' }))
@Controller('/invoices')
export class InvoicesController {}
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 {}
// JWT payload uses 'orgId' instead of 'tenantId'
@UseGuards(JwtAuthGuard, TenantGuard({ userField: 'orgId' }))
All options:
| Option | Type | Default | Description |
|---|---|---|---|
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:
| Condition | Status |
|---|---|
No req.user | 401 |
req.user has no tenant field | 403 |
| Tenant ID absent from request | 400 |
| Tenant IDs do not match | 403 |
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.
// src/orders/orders.repository.ts
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();
// Scoped automatically — no tenantId parameter needed
return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
}
findById(id: string) {
const tenantId = this.tenantCtx.requireId();
// Even direct ID lookup is tenant-scoped — prevents IDOR attacks
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() {
// TenantContext is already seeded by TenantGuard — no need to pass tenantId
return this.repo.findAll();
}
}
The two layers together:
| Layer | What it does | What it catches |
|---|---|---|
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:
// Background job — no HTTP request involved
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.
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!);
}
// Stricter restriction on a single route — only admin (and above) can delete:
@UseGuards(RoleGuard('admin'))
@Delete('/:id')
deleteOrder(@Param('id') id: string) {
return this.ordersService.remove(id);
}
}
@CurrentUser() decoratorInjects 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;
// { id: 'u1', username: 'alice', role: 'admin', tenantId: 'acme' }
}
@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() // JWT check only
@Get('/dashboard')
getDashboard() { ... }
@Auth({ roles: ['admin'] }) // JWT + role check (no hierarchy)
@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 APIimport { JwtService } from '@hazeljs/auth';
// Sign a token
const token = jwtService.sign({ sub: userId, role: 'admin', tenantId: 'acme' });
const token = jwtService.sign({ sub: userId }, { expiresIn: '15m' }); // custom expiry
// Verify and decode
const payload = jwtService.verify(token); // throws on invalid/expired
payload.sub // string
payload.role // string
payload.tenantId // string | undefined
// Decode without verification (e.g. to read exp before refreshing)
const payload = jwtService.decode(token); // returns null on malformed
AuthService APIAuthService wraps JwtService and returns a typed AuthUser object:
interface AuthUser {
id: string;
username?: string;
role: string;
[key: string]: unknown; // all other JWT claims pass through
}
const user = await authService.verifyToken(token);
// Returns AuthUser | null (null when token is invalid — never throws)
RoleHierarchy APIimport { RoleHierarchy, DEFAULT_ROLE_HIERARCHY } from '@hazeljs/auth';
const h = new RoleHierarchy(DEFAULT_ROLE_HIERARCHY);
h.satisfies('superadmin', 'user') // true — full chain
h.satisfies('manager', 'admin') // false — no upward inheritance
h.resolve('admin') // Set { 'admin', 'manager', 'user' }
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;
// ...
}
| Variable | Default | Description |
|---|---|---|
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 |
FAQs
Authentication and JWT module for HazelJS framework
We found that @hazeljs/auth demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.