DEV Community

Cover image for Multi-Tenant Auth, Roles, and Database Isolation with @hazeljs/auth
Muhammad Arslan
Muhammad Arslan

Posted on

Multi-Tenant Auth, Roles, and Database Isolation with @hazeljs/auth

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
Enter fullscreen mode Exit fullscreen mode

npm: @hazeljs/auth

What ships

  • JwtModule.forRoot() — configures JwtService with your secret and expiry; reads from env vars by default
  • JwtAuthGuard — a CanActivate guard that validates the Authorization: Bearer header and attaches the decoded payload to req.user
  • RoleGuard(role) — a guard factory that checks req.user.role against a required role, with a configurable inheritance hierarchy
  • TenantGuard(options) — a guard factory that enforces tenant isolation at the HTTP layer and seeds TenantContext
  • TenantContext — an AsyncLocalStorage-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
Enter fullscreen mode Exit fullscreen mode

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() { ... }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode
// 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() } });
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

@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',
})
Enter fullscreen mode Exit fullscreen mode
// main.ts — nothing special needed
const app = new HazelApp(AppModule);
await app.listen(port);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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() { ... }
Enter fullscreen mode Exit fullscreen mode

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 }))
Enter fullscreen mode Exit fullscreen mode

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)