Programming

AsyncLocalStorage Is Not a Security Boundary: Building Safer Multi-Tenant NestJS Applications

A practical distinction between carrying a tenant ID and deciding whether a caller may use it. The problem: a tenant ID can arrive correctly and still be wrong Consider a synthetic application where Alice belongs to tenant-a and Bob belongs to tenant-b. Alice sends a request with x-tenant-id: tenant-b. Middleware copies that value into AsyncLocalStorage (ALS). A repository reads the context and filters its query by tenant-b. The context propagation can work exactly as designed while the applicat

A practical distinction between carrying a tenant ID and deciding whether a caller may use it.

The problem: a tenant ID can arrive correctly and still be wrong

Consider a synthetic application where Alice belongs to tenant-a and Bob belongs to tenant-b. Alice sends a request with x-tenant-id: tenant-b. Middleware copies that value into AsyncLocalStorage (ALS). A repository reads the context and filters its query by tenant-b.

The context propagation can work exactly as designed while the application returns the wrong tenant's data. The missing step is membership authorization. This is a hypothetical failure path, not a report of a real incident.

ALS is useful here. The mistake is assigning it responsibility for deciding whether the selected tenant is allowed. A tenant identifier in a store is a value supplied by application code. Its presence does not establish its provenance.

Constraints and trust boundaries

This article uses fake identities, memberships and rows. The experiment ran on Node.js 22.18.0 with NestJS 11.1.6 and TypeScript 5.9.2. The recorded results are seven original core tests, two additional compiled context-restoration tests and five real HTTP tests passing. Type checking and compilation also passed. A retained lockfile pins the resolved dependencies. Core type stripping alone would not establish type-checking success.

The attacker in this model has a valid synthetic identity for tenant-a, controls tenant selection and resource IDs, and tries to read or change tenant-b's row. The application code and server-side membership fixture are trusted. Arbitrary code execution, stolen credentials and database compromise are outside this model.

Separate four decisions:

  1. Authenticate the caller through a trusted identity mechanism.
  2. Authorize the selected tenant against that caller's memberships and operation permissions.
  3. Carry the resulting context through the work.
  4. Enforce the scope whenever data is accessed.

The lab substitutes public demo bearer fixtures for step one. They are an instructional shortcut, not a deployable authentication scheme. Membership alone also does not express every real application's permissions: an update may require a role, ownership check or resource policy beyond tenant membership.

What ALS contributes

Node documents run() and getStore() as context APIs. A callback and asynchronous operations created within it can retrieve its store. Outside an initialized context, getStore() can return undefined. The documentation also discusses callback integration and context loss. Node documentation

That makes ALS a convenient transport for a validated context. It does not make every value in that context trustworthy. The negative control below demonstrates this distinction without relying on a context-loss bug.

A type assertion does not add the missing check:

// Illustrative unsafe shortcut — not the lab's authorized path.
const tenantId = request.headers['x-tenant-id'] as string;

The assertion changes what the compiler accepts, not what arrived over HTTP. TypeScript's handbook explicitly describes this absence of runtime validation. TypeScript handbook

Build the context from a checked selection

The excerpts below show the parts that establish the boundary. A complete source listing appears in the appendix; each excerpt alone is not a complete application.

The context and synthetic server-side memberships are defined in core.ts:

import { AsyncLocalStorage } from 'node:async_hooks';
export type AuthContext = Readonly<{ userId: string; tenantId: string }>;
export const context = new AsyncLocalStorage<AuthContext>();
const memberships: Record<string, readonly string[]> = {
  alice: ['tenant-a'], bob: ['tenant-b'],
};

The tested core has a small membership function. selected remains unknown until checked:

export function authorize(userId: string, selected: unknown): AuthContext {
  if (typeof selected !== 'string' || !Object.hasOwn(memberships, userId) ||
      !memberships[userId].includes(selected)) throw new Error('Forbidden');
  return Object.freeze({ userId, tenantId: selected });
}

userId must come from the trusted authentication path. Accepting a second user-controlled header as identity would undermine the membership lookup. A signed identity also needs the application's intended issuer, audience, expiration and session checks; those mechanisms are not implemented or validated by this lab.

Freezing this flat context discourages accidental mutation. It is not a sandbox against trusted application code constructing another context. The context type documents a contract; it does not make that contract impossible to violate.

Establish the NestJS request scope deliberately

Nest's ALS recipe uses middleware to wrap downstream work. The guards documentation places guards after middleware and before interceptors and pipes. ALS recipe, Guards

The tested HTTP wrapper therefore resolves its synthetic identity and validates membership before calling context.run(auth, next). This is the middleware body from app.ts:

// PUBLIC SYNTHETIC FIXTURES ONLY: not real authentication.
const userId = req.headers.authorization === 'Bearer demo-alice' ? 'alice'
  : req.headers.authorization === 'Bearer demo-bob' ? 'bob' : undefined;
if (!userId) throw new UnauthorizedException();
let auth: AuthContext;
try { auth = authorize(userId, req.headers['x-tenant-id']); }
catch { throw new ForbiddenException(); }
context.run(auth, next);

A real implementation must replace those public fixture tokens with its trusted identity mechanism. This middleware does not assume a guard has already populated identity before it executes. Its controller exposes a read route; write enforcement is demonstrated only in the core tests.

If your real authentication happens in a guard, this sample's ordering cannot be copied unchanged. Design and test a lifecycle in which context is available only after the relevant trust checks, or pass the validated context explicitly. Do not call run() around a guard's return value and assume that proves the later controller executes within that context. That integration needs an actual HTTP test.

The lab includes a compiled app factory, a lockfile and five passing HTTP cases. A trusted test hook uses a barrier: both handlers must enter before either resumes, and both assert their tenant context after await. A separate case injects an asynchronous failure and verifies the next tenant request. These results cover the tested Express paths, not every adapter, callback library or deployment.

Fail closed at data access

A missing store should not silently select a default tenant:

export function requiredContext(): AuthContext {
  const auth = context.getStore();
  if (!auth) throw new Error('Missing tenant context');
  return auth;
}

The in-memory read function combines resource identity and tenant identity:

export function readRow(id: string) {
  const auth = requiredContext();
  const row = rows.find(r => r.id === id && r.tenantId === auth.tenantId);
  if (!row) throw new Error('Not found');
  return { ...row };
}

The write function uses the same predicate and accepts only replacement text. It does not accept an arbitrary object that could reassign tenantId. The read returns a copy so a caller cannot modify the stored row through the returned object. These are properties of this small implementation, not a claim that all repositories enforce scope.

For a real repository, review every access path: listing, lookup, updates, deletes, bulk operations, raw queries and relationship traversal. The lab does not implement or verify those paths. The enforcement rule must be expressed in the actual persistence layer rather than inferred from this array example.

Test the attack, then test a deliberate bypass

The original execution record used Node directly for the seven-test core suite:

node --experimental-strip-types --test core.test.ts

The original seven core tests passed unchanged. They cover own-tenant reads and writes; forged or missing tenant selection and unknown identity; missing context; foreign reads and writes with a victim-row integrity assertion; overlapping async chains; synchronous throw cleanup; and a deliberate bypass. The reported results come from the retained local execution record, with runtime versions and source hashes; that record is not publicly hosted.

The bypass directly constructs { userId: 'alice', tenantId: 'tenant-b' } and calls context.run() without authorize(). Reading Bob's row then succeeds. That successful attack is the expected negative control. It shows the repository trusts the selected context and ALS does not repair unauthorized input.

This is also the limit of the design: trusted code must preserve the validated entry path. If your threat model includes malicious code in the same process, this arrangement is not an isolation mechanism against it.

The overlap test creates two async chains with separate stores and coordinates them through a promise. It checks the selected context and a foreign lookup while work overlaps. It is useful evidence about those paths on this runtime. The separate HTTP barrier tests extend the observation to the covered NestJS Express paths. Neither suite proves arbitrary callback libraries, other HTTP adapters or job workers preserve context.

Reproduce the verified checks

The lab ran on Darwin arm64 with Node.js 22.18.0, npm 10.9.3, TypeScript 5.9.2 and NestJS 11.1.6. The appendix includes the complete application, tests, manifest and compiler configuration. No database is required. The recorded run used a retained lockfile. The lockfile is not printed in this article: a new install from the listed direct pins may resolve different transitive versions, so it is a reconstruction rather than an exact dependency-tree replay.

After saving the appendix files together in a disposable directory and installing the listed dependencies, these are the npm script actions used in the recorded verification. The recorded invocations also included workspace-local npm cache/config flags:

npm run typecheck
npm run build
npm run test:core
npm run test:additional
npm run test:http

Build before the compiled suites. The compiler emits JavaScript into dist/, including decorator metadata and relative import extension rewriting. The HTTP suite creates its own Nest application on an ephemeral loopback port and closes it after each case; do not start a second server first. An environment that prohibits local listening must grant that permission for HTTP tests to execute.

The retained successful results are:

Check Observed result
Type checking and JavaScript build Both exit 0
Original core suite 7 passed
Additional compiled context-restoration suite 2 passed
NestJS HTTP and lifecycle suite 5 passed

The initial installation DNS failure and loopback permission failure are retained alongside the successful retries. The final 14 cases passed with no failed, skipped or cancelled cases in those successful runs. These are correctness observations, not performance measurements. The raw execution record is retained locally and is not publicly hosted.

Alternatives and tradeoffs

Passing an explicit AuthContext through service and repository arguments makes the dependency visible. It adds plumbing but can make boundaries easier to inspect. It still needs runtime validation and a trusted creation path; moving a value into an argument does not authorize it. This is a design judgment, not a benchmark conclusion.

PostgreSQL row-level security (RLS) is another enforcement layer worth evaluating for shared tables. Its documentation describes role and policy behavior, including owner and bypass privileges. PostgreSQL documentation

No database was used here. A follow-up would need the actual application role, policy, connection handling, cross-tenant reads and writes, missing tenant state, transaction failure and pooled connection reuse tests. Merely adding a tenant session setting would not demonstrate that authorization or cleanup is correct.

Database-per-tenant designs move part of the problem into routing and operations. They still need evidence that a caller cannot select the wrong tenant's database. This article does not measure their resource cost or verify routing behavior.

For jobs or messages, define a fresh trusted entry path. A serialized tenant field is input to validate against the system's job authorization model. Do not treat possession of that field as proof of authority. Worker integration remains untested here.

Appendix: complete source listing

Save the eight files below together. Their code is copied from the tested local lab. The manifest pins direct dependency versions; the original lockfile and raw logs are retained locally rather than linked to an unavailable public repository.

For a new disposable directory, the following is a proposed reconstruction command, not a fresh command executed for this article export:

npm install --ignore-scripts --no-audit --no-fund

It generates a new lockfile and may resolve different transitive dependencies from the recorded run. Then execute the typecheck, build and three test scripts shown above. Test outcomes on a reconstructed environment are not reported as observed here. The HTTP tests start and close their own server; main.ts is only the optional manual entry point.

package.json

{
  "name": "mt-01-als-security-boundary",
  "private": true,
  "type": "module",
  "scripts": {
    "test:core": "node --experimental-strip-types --test core.test.ts",
    "typecheck": "tsc --noEmit",
    "start": "node dist/main.js",
    "test:http": "node --test http.test.mjs",
    "build": "tsc",
    "test:additional": "node --test dist/additional.test.js"
  },
  "dependencies": {
    "@nestjs/common": "11.1.6",
    "@nestjs/core": "11.1.6",
    "@nestjs/platform-express": "11.1.6",
    "reflect-metadata": "0.2.2",
    "rxjs": "7.8.2"
  },
  "devDependencies": {
    "typescript": "5.9.2",
    "@types/node": "22.18.0"
  },
  "engines": {
    "node": ">=22.18.0 <23",
    "npm": ">=10.9.3 <11"
  },
  "packageManager": "[email protected]"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "allowImportingTsExtensions": true,
    "noEmit": false,
    "outDir": "dist",
    "rootDir": ".",
    "rewriteRelativeImportExtensions": true
  },
  "include": [
    "*.ts"
  ]
}

core.ts

import { AsyncLocalStorage } from 'node:async_hooks';
export type AuthContext = Readonly<{ userId: string; tenantId: string }>;
export const context = new AsyncLocalStorage<AuthContext>();
// Synthetic server-side identity/membership fixtures; never accept userId from a header.
const memberships: Record<string, readonly string[]> = { alice: ['tenant-a'], bob: ['tenant-b'] };
export function authorize(userId: string, selected: unknown): AuthContext {
  if (typeof selected !== 'string' || !Object.hasOwn(memberships, userId) ||
      !memberships[userId].includes(selected)) throw new Error('Forbidden');
  return Object.freeze({ userId, tenantId: selected });
}
export function requiredContext(): AuthContext {
  const auth = context.getStore();
  if (!auth) throw new Error('Missing tenant context');
  return auth;
}
const rows = [
  { id: 'a1', tenantId: 'tenant-a', text: 'Synthetic A' },
  { id: 'b1', tenantId: 'tenant-b', text: 'Synthetic B' },
];
export function readRow(id: string) {
  const auth = requiredContext();
  const row = rows.find(r => r.id === id && r.tenantId === auth.tenantId);
  if (!row) throw new Error('Not found');
  return { ...row };
}
export function updateRow(id: string, text: string) {
  const auth = requiredContext();
  const row = rows.find(r => r.id === id && r.tenantId === auth.tenantId);
  if (!row) throw new Error('Not found');
  row.text = text;
  return { ...row };
}

core.test.ts

import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import { authorize, context, requiredContext, readRow, updateRow } from './core.ts';
test('authorized read and write', () => context.run(authorize('alice', 'tenant-a'), () => {
  assert.equal(readRow('a1').tenantId, 'tenant-a');
  assert.equal(updateRow('a1', 'Synthetic updated A').text, 'Synthetic updated A');
}));
test('reject forged selection, absent selection and unknown identity', () => {
  assert.throws(() => authorize('alice', 'tenant-b'), /Forbidden/);
  assert.throws(() => authorize('alice', undefined), /Forbidden/);
  assert.throws(() => authorize('mallory', 'tenant-a'), /Forbidden/);
});
test('missing context fails closed', () => {
  assert.throws(() => readRow('a1'), /Missing/);
  assert.throws(() => updateRow('a1', 'attack'), /Missing/);
});
test('cross-tenant read and write denied; victim row intact', () => {
  context.run(authorize('alice', 'tenant-a'), () => {
    assert.throws(() => readRow('b1'), /Not found/);
    assert.throws(() => updateRow('b1', 'attack'), /Not found/);
  });
  context.run(authorize('bob', 'tenant-b'), () => assert.equal(readRow('b1').text, 'Synthetic B'));
});
test('overlapping chains retain separate context', async () => {
  let release!: () => void;
  const gate = new Promise<void>(resolve => { release = resolve; });
  await Promise.all([
    context.run(authorize('alice', 'tenant-a'), async () => {
      await gate;
      assert.equal(requiredContext().tenantId, 'tenant-a');
      assert.throws(() => readRow('b1'), /Not found/);
    }),
    context.run(authorize('bob', 'tenant-b'), async () => {
      release(); await delay(1);
      assert.equal(requiredContext().tenantId, 'tenant-b');
      assert.equal(readRow('b1').tenantId, 'tenant-b');
    }),
  ]);
});
test('throw restores outer context; subsequent operation works', () => {
  assert.throws(() => context.run(authorize('alice', 'tenant-a'), () => { throw new Error('synthetic failure'); }));
  assert.equal(context.getStore(), undefined);
  context.run(authorize('bob', 'tenant-b'), () => assert.equal(readRow('b1').id, 'b1'));
});
test('negative control: ALS transports an unauthorized tenant unchanged', () => {
  // Deliberate bypass of authorize: demonstrates the boundary, not a safe entry point.
  context.run({ userId: 'alice', tenantId: 'tenant-b' }, () => assert.equal(readRow('b1').id, 'b1'));
});

additional.test.ts

import test from 'node:test';
import assert from 'node:assert/strict';
import { authorize, context, readRow } from './core.ts';

test('async rejection restores caller context and next tenant works', async () => {
  await assert.rejects(context.run(authorize('alice', 'tenant-a'), async () => {
    await Promise.resolve();
    assert.equal(readRow('a1').tenantId, 'tenant-a');
    throw new Error('synthetic rejection');
  }), /synthetic rejection/);
  assert.equal(context.getStore(), undefined);
  await context.run(authorize('bob', 'tenant-b'), async () => {
    await Promise.resolve();
    assert.equal(readRow('b1').tenantId, 'tenant-b');
    assert.throws(() => readRow('a1'), /Not found/);
  });
});

test('nested context restores the outer tenant', async () => {
  await context.run(authorize('alice', 'tenant-a'), async () => {
    await context.run(authorize('bob', 'tenant-b'), async () => {
      await Promise.resolve();
      assert.equal(readRow('b1').tenantId, 'tenant-b');
    });
    assert.equal(readRow('a1').tenantId, 'tenant-a');
    assert.throws(() => readRow('b1'), /Not found/);
  });
  assert.equal(context.getStore(), undefined);
});

app.ts

import 'reflect-metadata';
import { Controller, Get, Param, Module, ForbiddenException,
  UnauthorizedException, NotFoundException, MiddlewareConsumer,
  NestModule, Inject } from '@nestjs/common';
import type { AuthContext } from './core.ts';
import { NestFactory } from '@nestjs/core';
import { authorize, context, readRow, requiredContext } from './core.ts';
import type { IncomingMessage } from 'node:http';

// A test-injected barrier verifies real overlap without a publicly selectable test route.
// The hook is application-trusted code; never populate it from an HTTP payload.
export type BeforeRead = (auth: AuthContext) => Promise<void>;
const BEFORE_READ = Symbol('BEFORE_READ');
@Controller('rows')
class RowsController {
  constructor(@Inject(BEFORE_READ) private readonly beforeRead: BeforeRead) {}
  @Get(':id')
  async read(@Param('id') id: string) {
    const auth = requiredContext();
    await this.beforeRead(auth);
    // Check the context again after the asynchronous hook via readRow.
    try { return readRow(id); }
    catch (error) {
      if (error instanceof Error && error.message === 'Not found') throw new NotFoundException();
      throw error;
    }
  }
}

export async function createApp(beforeRead: BeforeRead = async () => {}) {
  @Module({ controllers: [RowsController], providers: [{ provide: BEFORE_READ, useValue: beforeRead }] })
  class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
      consumer.apply((req: IncomingMessage, _res: unknown, next: () => void) => {
        // PUBLIC SYNTHETIC FIXTURES ONLY: not real authentication.
        const userId = req.headers.authorization === 'Bearer demo-alice' ? 'alice'
          : req.headers.authorization === 'Bearer demo-bob' ? 'bob' : undefined;
        if (!userId) throw new UnauthorizedException();
        let auth: AuthContext;
        try { auth = authorize(userId, req.headers['x-tenant-id']); }
        catch { throw new ForbiddenException(); }
        context.run(auth, next);
      }).forRoutes(RowsController);
    }
  }
  return NestFactory.create(AppModule, { logger: false });
}

main.ts

import { createApp } from './app.ts';
const app = await createApp();
await app.listen(3000, '127.0.0.1');

http.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './dist/app.js';
import { context } from './dist/core.js';

async function withApp(hook, body) {
  const app = await createApp(hook);
  try {
    await app.listen(0, '127.0.0.1');
    assert.equal(app.isInitialized, true);
    await body(await app.getUrl());
  } finally {
    await app.close();
  }
  assert.equal(app.getHttpServer().listening, false);
  assert.equal(context.getStore(), undefined);
}
const get = (base, id, token, tenant) => fetch(`${base}/rows/${id}`, {
  signal: AbortSignal.timeout(5000),
  headers: { ...(token ? {authorization: `Bearer ${token}`} : {}),
    ...(tenant ? {'x-tenant-id': tenant} : {}) }
});
async function expectStatus(base, id, token, tenant, status) {
  const response = await get(base, id, token, tenant);
  assert.equal(response.status, status);
  const body = await response.json();
  if (status !== 200) {
    assert.equal(body.statusCode, status);
    assert.equal(JSON.stringify(body).includes('Synthetic'), false);
    assert.equal(Object.hasOwn(body, 'tenantId'), false);
  }
  return body;
}

test('Nest bootstrap, HTTP own-tenant reads and shutdown', async () => {
  await withApp(undefined, async base => {
    assert.equal((await expectStatus(base, 'a1', 'demo-alice', 'tenant-a', 200)).tenantId, 'tenant-a');
    assert.equal((await expectStatus(base, 'b1', 'demo-bob', 'tenant-b', 200)).tenantId, 'tenant-b');
  });
});

test('HTTP rejects missing/invalid identity and missing/invalid/foreign tenant', async () => {
  await withApp(undefined, async base => {
    for (const [id, token, tenant, status] of [
      ['a1', undefined, 'tenant-a', 401], ['a1', 'invalid', 'tenant-a', 401],
      ['a1', 'demo-alice', undefined, 403], ['a1', 'demo-alice', 'tenant-unknown', 403],
      ['b1', 'demo-alice', 'tenant-b', 403], ['a1', 'demo-bob', 'tenant-a', 403],
    ]) await expectStatus(base, id, token, tenant, status);
    await expectStatus(base, 'a1', 'demo-alice', 'tenant-a', 200);
  });
});

test('HTTP rejects foreign row IDs in both directions and recovers after failure', async () => {
  await withApp(undefined, async base => {
    await expectStatus(base, 'b1', 'demo-alice', 'tenant-a', 404);
    await expectStatus(base, 'a1', 'demo-bob', 'tenant-b', 404);
    await expectStatus(base, 'nonexistent', 'demo-alice', 'tenant-a', 404);
    await expectStatus(base, 'a1', 'demo-alice', 'tenant-a', 200);
    await expectStatus(base, 'b1', 'demo-bob', 'tenant-b', 200);
  });
});

test('HTTP deterministic overlapping tenant requests retain context across await', async () => {
  const entered = [];
  const resumed = [];
  let release;
  const gate = new Promise(resolve => { release = resolve; });
  const hook = async auth => {
    entered.push(auth.tenantId);
    if (entered.length === 2) release();
    // First handler cannot resume until the second has entered; timer only prevents hangs.
    let timer;
    try {
      await Promise.race([gate, new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error('overlap barrier timeout')), 3000);
      })]);
    } finally { clearTimeout(timer); }
    assert.equal(context.getStore()?.tenantId, auth.tenantId);
    resumed.push(auth.tenantId);
  };
  await withApp(hook, async base => {
    const [a, b] = await Promise.all([
      expectStatus(base, 'a1', 'demo-alice', 'tenant-a', 200),
      expectStatus(base, 'b1', 'demo-bob', 'tenant-b', 200),
    ]);
    assert.deepEqual([a.tenantId, b.tenantId], ['tenant-a', 'tenant-b']);
    assert.deepEqual([...entered].sort(), ['tenant-a', 'tenant-b']);
    assert.deepEqual([...resumed].sort(), ['tenant-a', 'tenant-b']);
  });
});

test('HTTP async handler failure does not leak context to the next request', async () => {
  let failOnce = true;
  await withApp(async auth => {
    await Promise.resolve();
    assert.equal(context.getStore()?.tenantId, auth.tenantId);
    if (failOnce) { failOnce = false; throw new Error('synthetic failure'); }
  }, async base => {
    await expectStatus(base, 'a1', 'demo-alice', 'tenant-a', 500);
    assert.equal((await expectStatus(base, 'b1', 'demo-bob', 'tenant-b', 200)).tenantId, 'tenant-b');
    await expectStatus(base, 'a1', 'demo-bob', 'tenant-b', 404);
  });
});

What this demonstrates

The supported conclusion is narrow: in this synthetic core, membership validation and tenant-scoped repository predicates reject the covered foreign operations. ALS carries the chosen context, including an unauthorized context when code bypasses validation.

The recorded verification adds two context-restoration tests and five NestJS HTTP cases to the original seven, for 14 passing cases. Typecheck and build exited 0. The HTTP suite covers own rows, invalid identity and tenant selection, foreign IDs in both directions, forced overlap across await, and recovery after an async failure. HTTP exposes reads only; writes are tested at the core layer. The initial DNS and loopback permission failures remain in the record alongside successful retries. The raw execution evidence is retained locally and is not publicly hosted. No database isolation or production readiness claim follows from these tests.

Which entry path in your application can reach a tenant repository without first validating the selected tenant?