I just shipped authentication and authorization support for HazelJS. The new @hazeljs/auth package gives you JWT issuance and verification, role-based access control with an inheritance hierarchy, tenant-level HTTP isolation, and a clean AsyncLocalStorage-based context that propagates the tenant ID all the way down to your database queries — without passing it through every function signature.
To show what this looks like end-to-end I built hazeljs-auth-roles-starter: a multi-tenant task management API where every design decision is deliberate and every security boundary is enforced in two places.
The problem this solves
Most auth tutorials stop at "put a guard on the route." That is necessary but not sufficient in a multi-tenant system. You also need the database layer to be naturally scoped — so that even if a developer forgets to add WHERE organizationId = ? to a new query, the worst case is an empty result set, not a data breach.
@hazeljs/auth attacks both layers:
| Layer | Mechanism |
|---|---|
| HTTP |
TenantGuard compares user.tenantId (from JWT) against the :orgId URL param and returns 403 on mismatch |
| Database |
TenantContext (backed by AsyncLocalStorage) makes the tenant ID available inside every repository without passing it as a parameter |
The two layers are independent but complementary. TenantGuard catches the most common attack (a user crafting a URL for a different org). TenantContext is the safety net for the query layer.
Package overview
npm install @hazeljs/auth
npm: @hazeljs/auth
What ships
-
JwtModule.forRoot()— configuresJwtServicewith your secret and expiry; reads from env vars by default -
JwtAuthGuard— aCanActivateguard that validates theAuthorization: Bearerheader and attaches the decoded payload toreq.user -
RoleGuard(role)— a guard factory that checksreq.user.roleagainst a required role, with a configurable inheritance hierarchy -
TenantGuard(options)— a guard factory that enforces tenant isolation at the HTTP layer and seedsTenantContext -
TenantContext— anAsyncLocalStorage-backed service for propagating the tenant ID through the entire async call chain -
@CurrentUser(field?)— a parameter decorator that injects the authenticated user (or a single field from it) into controller methods -
@Auth()— legacy all-in-one decorator for simple JWT + role checks
Role hierarchy
Roles form a tree, not a flat list. The default hierarchy ships with the package:
superadmin
└── admin
└── manager
└── user
RoleGuard('manager') passes for manager, admin, and superadmin. You never enumerate all valid roles — you specify the minimum required level and the hierarchy takes care of the rest. You can replace this with any custom map:
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy: {
owner: ['editor'],
editor: ['viewer'],
viewer: [],
}}))
@Delete('/:id')
deletePost() { ... }
Two-layer tenant isolation
This is the part I'm most pleased with. Here is the full data flow for a single request:
GET /orgs/abc-123/tasks
Authorization: Bearer <token with tenantId: "abc-123", role: "manager">
1. JwtAuthGuard → verifies token
attaches req.user = { sub, role, tenantId, ... }
2. TenantGuard → reads user.tenantId ("abc-123")
reads :orgId param ("abc-123")
compares → match ✓
calls TenantContext.enterWith("abc-123")
3. RoleGuard('user') → user.role = "manager"; manager ≥ user ✓
4. TasksController → calls tasksService.findAll()
5. TasksRepository → this.tenantCtx.requireId() → "abc-123"
SELECT * FROM tasks WHERE organizationId = 'abc-123'
Step 2 is the HTTP guard. Step 5 is the database guard. If somehow a bug bypassed step 2, step 5 would still scope the query to the authenticated user's own tenant — because TenantContext uses AsyncLocalStorage, which is propagated automatically through the async call chain. There is no "forgot to pass orgId" scenario.
Setting it up
// tasks.controller.ts
@UseGuards(JwtAuthGuard, TenantGuard({ source: 'param', key: 'orgId' }))
@Controller('/orgs/:orgId/tasks')
export class TasksController { ... }
// tasks.repository.ts
@Injectable()
export class TasksRepository extends BaseRepository<Task> {
constructor(
typeOrm: TypeOrmService,
private readonly tenantCtx: TenantContext,
) {
super(typeOrm, Task);
}
findAll(): Promise<Task[]> {
// tenantCtx.requireId() reads from AsyncLocalStorage — no parameter needed
return this.find({ where: { organizationId: this.tenantCtx.requireId() } });
}
}
The TenantContext is not injected from a request scope — it uses AsyncLocalStorage so the tenant ID set in the guard is available in any code running in the same async context, however deep the call chain goes.
The starter project
hazeljs-auth-roles-starter is a complete, runnable task management API built on this foundation. The domain is:
Organisation (tenant)
└── Users (roles: user | manager | admin | superadmin)
└── Tasks (status, priority, assignee)
What's in it
| Route | Guard stack | Minimum role |
|---|---|---|
POST /auth/register |
— | — |
POST /auth/login |
— | — |
GET /auth/me |
JwtAuthGuard |
any |
GET /orgs/:orgId/tasks |
JwtAuthGuard → TenantGuard → RoleGuard |
user |
POST /orgs/:orgId/tasks |
JwtAuthGuard → TenantGuard → RoleGuard |
manager |
PATCH /orgs/:orgId/tasks/:id |
JwtAuthGuard → TenantGuard → RoleGuard |
manager |
DELETE /orgs/:orgId/tasks/:id |
JwtAuthGuard → TenantGuard → RoleGuard |
admin |
GET /orgs/:orgId/members |
JwtAuthGuard → TenantGuard → RoleGuard |
manager |
PATCH /orgs/:orgId/members/:id/role |
JwtAuthGuard → TenantGuard → RoleGuard |
admin |
Running it
git clone https://github.com/hazel-js/hazeljs.git
cd hazeljs-auth-roles-starter
docker-compose up -d # Postgres on localhost:5433
cp .env.example .env
npm install
npm run dev
Registering users and testing tenant isolation
# Create Acme Corp and Alice
curl -s -X POST http://localhost:3000/auth/register \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","email":"alice@acme.com","password":"s3cr3t","orgName":"Acme Corp"}' | jq .
# A completely separate tenant — Globex
curl -s -X POST http://localhost:3000/auth/register \
-H 'Content-Type: application/json' \
-d '{"name":"Eve","email":"eve@globex.com","password":"s3cr3t","orgName":"Globex"}' | jq .
ALICE_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@acme.com","password":"s3cr3t"}' | jq -r .token)
EVE_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"eve@globex.com","password":"s3cr3t"}' | jq -r .token)
ALICE_ORG=$(curl -s http://localhost:3000/auth/me \
-H "Authorization: Bearer $ALICE_TOKEN" | jq -r .organizationId)
# Eve trying to read Alice's org tasks → 403
curl -s "http://localhost:3000/orgs/$ALICE_ORG/tasks" \
-H "Authorization: Bearer $EVE_TOKEN"
# {"statusCode":403,"message":"Access denied: resource belongs to a different tenant"}
That 403 comes from TenantGuard comparing Eve's JWT (tenantId: globex-id) against $ALICE_ORG. It never reaches the database.
How @CurrentUser works
Once JwtAuthGuard attaches req.user, the @CurrentUser() decorator injects it into your controller without any boilerplate:
@UseGuards(JwtAuthGuard)
@Get('/me')
async me(@CurrentUser('sub') userId: string) {
return this.usersRepo.findById(userId);
}
@CurrentUser() injects the entire user object. @CurrentUser('sub') injects just the sub field. Under the hood it stores { type: 'user', field: 'sub' } as parameter injection metadata and the router handles the rest.
TypeORM integration — zero boilerplate
The @hazeljs/typeorm package now auto-initialises its DataSource when the DI container creates the service — no onModuleInit call in main.ts, no lifecycle hook wiring. The connection starts as soon as the module bootstraps:
// app.module.ts
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [Organization, User, Task],
synchronize: process.env.DB_SYNCHRONIZE === 'true',
})
// main.ts — nothing special needed
const app = new HazelApp(AppModule);
await app.listen(port);
If you want to block the server from accepting requests until the database is definitely ready, TypeOrmService.ready() returns the initialization promise:
await app.getContainer().resolve(TypeOrmService).ready();
await app.listen(port);
Extending the role hierarchy
The default hierarchy is opinionated but not mandatory. Pass a custom map to any RoleGuard call:
const BILLING_HIERARCHY = {
billing_admin: ['billing_viewer'],
billing_viewer: [],
};
@UseGuards(JwtAuthGuard, RoleGuard('billing_viewer', { hierarchy: BILLING_HIERARCHY }))
@Get('/invoices')
listInvoices() { ... }
Or share a RoleHierarchy instance across multiple guards:
import { RoleHierarchy } from '@hazeljs/auth';
export const AppHierarchy = new RoleHierarchy({
owner: ['editor', 'viewer'],
editor: ['viewer'],
viewer: [],
});
// In any controller:
@UseGuards(JwtAuthGuard, RoleGuard('editor', { hierarchy: AppHierarchy }))
What's next
-
@hazeljs/casl— attribute-level permissions (can this user edit this specific record?) -
OAuth2 provider support in
@hazeljs/oauth -
Refresh token rotation in
JwtModule
The starter, the package source, and all tests are in the HazelJS monorepo. PRs and issues welcome.
Top comments (0)