Skip to content

Troubleshooting

better-tenant: tenant could not be resolved from request

This means none of your configured resolver strategies found a tenant identifier on the incoming request. The middleware responds with a 404 (or 401 if configured via missingTenantStatus).

Header is missing or misspelled. If you use header: "x-tenant-id", the request must include that exact header:

Terminal window
# Wrong — header name doesn't match
curl -H "X-Tenant: abc" http://localhost:3000/projects
# Correct
curl -H "x-tenant-id: abc" http://localhost:3000/projects

Subdomain not detected on localhost. The subdomain resolver needs at least 3 hostname segments. localhost and example.com don’t have a subdomain:

HostExtracted subdomain
acme.app.comacme
acme.app.localhostacme
acme.localhostnothing — only 2 segments
localhostnothing — too few segments
example.comnothing — only 2 segments

Path resolver doesn’t match. The path pattern must include :tenantId. Check that the segment index is correct:

// Matches /t/acme/projects — tenantId is "acme"
tenantResolver: {
path: "/t/:tenantId/*";
}
// Does NOT match /projects/acme — wrong segment

JWT claim is missing or not a string. The resolver decodes the JWT payload and reads the configured claim. It returns nothing if the claim doesn’t exist, is not a string, or the token is malformed.

Resolution order matters. Strategies are tried in this order: header → path → subdomain → JWT → custom. The first one that returns a non-empty value wins. If you configure multiple strategies, an earlier one may match before the one you expect.

By default, all framework adapters return a 404 with a JSON body. You can change the status or handle it yourself:

// Change status to 401
createHonoMiddleware(tenant, { missingTenantStatus: 401 });
createExpressMiddleware(tenant, { missingTenantStatus: 401 });
withTenant(tenant, handler, { missingTenantStatus: 401 });
// Full custom handling (Hono)
createHonoMiddleware(tenant, {
onMissingTenant: (c) => c.json({ error: "Unknown workspace" }, 404),
});
// Full custom handling (Express)
createExpressMiddleware(tenant, {
onMissingTenant: (req, res) => {
res.status(403).json({ error: "Unknown workspace" });
},
});

Queries return data from all tenants instead of filtering to the current tenant — tenant isolation is not working.

Check if you’re connecting as a superuser

Section titled “Check if you’re connecting as a superuser”

PostgreSQL superusers bypass all RLS policies, even with FORCE ROW LEVEL SECURITY. The default postgres user in most Docker and cloud setups is a superuser.

Check your current role:

SELECT current_user, usesuper FROM pg_user WHERE usename = current_user;

If usesuper is true, RLS will never filter rows for that connection. Create a non-superuser application role:

CREATE ROLE app_user WITH LOGIN PASSWORD '**********';
GRANT CONNECT ON DATABASE mydb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;

Then update your DATABASE_URL to use the new role.

If the middleware is not on the route’s path, no transaction with SET LOCAL is opened and RLS has no tenant to filter by. Verify your middleware is applied to the correct routes (see Framework Adapters).


Queries return empty results even though rows exist in the database. This usually means the RLS policy can’t match app.current_tenant to the rows’ tenant_id.

Terminal window
npx @usebetterdev/tenant-cli check --database-url $DATABASE_URL

The check command runs 10+ validations and pinpoints the exact issue. Here’s what each failure means and how to fix it:

FailureMeaningFix
tenants table not foundThe tenants table doesn’t existRun the migration: psql $DATABASE_URL -f ./migrations/*_better_tenant.sql
tenants table missing column: <col>The tenants table is missing a required column (id, name, slug, created_at)Re-run the migration or add the column manually
table <name> not foundA table listed in tenantTables doesn’t exist in the databaseCreate the table first, then re-run the migration
column tenant_id not foundThe table is missing the tenant_id columnRe-run the migration or add: ALTER TABLE <name> ADD COLUMN tenant_id UUID NOT NULL REFERENCES tenants(id)
ROW LEVEL SECURITY not enabledRLS is not turned on for this tableRun: ALTER TABLE <name> ENABLE ROW LEVEL SECURITY
FORCE ROW LEVEL SECURITY not enabledRLS can be bypassed by the table owner roleRun: ALTER TABLE <name> FORCE ROW LEVEL SECURITY
no RLS policy foundNo policy exists on this tableRe-run the migration to generate the policy
policy missing USING expressionThe policy doesn’t filter readsRe-create the policy with a USING clause
policy missing WITH CHECK expressionThe policy doesn’t validate writesRe-create the policy with a WITH CHECK clause
policy should allow bypass_rls for runAsSystemThe policy doesn’t include the app.bypass_rls escape hatchRe-create the policy with OR current_setting('app.bypass_rls', true) = 'true' in both clauses
trigger set_tenant_id_trigger not foundThe auto-populate trigger is missingRe-run the migration to create it

If you use subdomain or path-based resolution, the resolver extracts a slug (like "acme") and looks it up in the tenants table to get the UUID. If no tenant with that slug exists, resolution returns undefined and you get a “tenant could not be resolved” error — not an “empty results” error.

Make sure the tenant exists:

Terminal window
npx @usebetterdev/tenant-cli seed --name "Acme Corp" --slug "acme" --database-url $DATABASE_URL

UseBetter Tenant uses set_config('app.current_tenant', '<uuid>', true) where the third argument true means transaction-local. The session variable is automatically cleared when the transaction commits. This makes it safe with connection pools — the next request gets a fresh transaction with no leftover state.

PgBouncer must run in transaction mode (the default) for SET LOCAL / set_config(..., true) to work correctly. In session mode, the session variable persists across transactions on the same connection, which can leak tenant context between requests.

pgbouncer.ini
pool_mode = transaction # correct — SET LOCAL is cleared per transaction
# pool_mode = session # wrong — session variables persist across requests

Each request gets its own database transaction. The app.current_tenant variable is scoped to that transaction and invisible to other concurrent requests on the same connection. When the transaction ends (commit or rollback), the variable is gone.


tenant.getContext() and tenant.getDatabase() return undefined when called outside a tenant scope.

Calling outside middleware. These methods only work inside a request handled by tenant middleware, or inside a runAs / runAsSystem block:

// This works — inside middleware scope
app.get("/projects", async (c) => {
const db = tenant.getDatabase(); // returns the scoped database
});
// This does NOT work — outside any scope
const db = tenant.getDatabase(); // undefined

Calling inside setTimeout or detached async. UseBetter Tenant uses AsyncLocalStorage to propagate context. Some patterns break the async chain:

app.get("/projects", async (c) => {
// Works — same async context
const db = tenant.getDatabase();
// Does NOT work — setTimeout creates a new async context
setTimeout(() => {
const db = tenant.getDatabase(); // undefined
}, 1000);
});

If you need to run tenant-scoped work outside the request lifecycle, capture the tenant ID and use runAs:

app.get("/projects", async (c) => {
const ctx = tenant.getContext();
const tenantId = ctx.tenantId;
// Schedule work with explicit tenant scope
setTimeout(async () => {
await tenant.runAs(tenantId, async (db) => {
// tenant context is available here
});
}, 1000);
});

column "tenant_id" of relation "User" does not exist

The set_tenant_id() trigger and RLS policies reference the column as tenant_id (snake_case). Prisma’s default field mapping uses camelCase (tenantId → column tenantId).

Fix: Add @map("tenant_id") to every tenantId field in your Prisma schema:

model User {
tenantId String @map("tenant_id") @db.Uuid
// ...
}

table "users" not found

The CLI config tenantTables must use the actual PostgreSQL table name, not the Prisma model name:

  • Prisma model User (no @@map) → PG table "User" → config: "User"
  • Prisma model User + @@map("users") → PG table users → config: "users"

Recommendation: Add @@map("lowercase") to all models and use lowercase names in config. See CLI & Migrations — Configuration.


Prisma: getDatabase() has no model methods

Section titled “Prisma: getDatabase() has no model methods”
const db = tenant.getDatabase();
db.project.findMany(); // TS error: Property 'project' does not exist on type 'unknown'

getDatabase() is fully typed via generics when you pass a typed PrismaClient to prismaDatabase(). If you’re seeing unknown, make sure the generic is inferred correctly — pass the PrismaClient instance directly to prismaDatabase(prisma).

See the Prisma guide for details.


Prisma: seed script fails after adding tenancy

Section titled “Prisma: seed script fails after adding tenancy”
null value in column "tenant_id" violates not-null constraint

After adding RLS, the set_tenant_id() trigger needs app.current_tenant to be set in the transaction. Direct prisma.user.create() calls outside a tenant context have no transaction with SET LOCAL, so the trigger can’t populate tenant_id.

Fix: Use tenant.runAs() or tenant.api.createTenant() for seeding:

const acme = await tenant.api.createTenant({ name: "Acme", slug: "acme" });
await tenant.runAs(acme.id, async (db) => {
await db.user.create({ data: { email: "[email protected]", name: "Admin" } });
});

See the Prisma guide — Seeding for full examples.


Prisma doesn’t support custom SQL migration files like Drizzle. If you apply RLS via psql directly, Prisma’s migration history won’t know about it, and prisma migrate dev may warn about drift.

Fix: Embed RLS SQL in a Prisma migration directory. See Prisma guide — Migration workflow for step-by-step instructions.


Error: No file ./drizzle/0001_better_tenant_rls.sql found in ./drizzle folder

This means Drizzle Kit’s journal (drizzle/meta/_journal.json) references a migration file that doesn’t exist on disk. This typically happens when a previous drizzle-kit generate run was partially completed or when migration files were manually deleted.

  1. Open drizzle/meta/_journal.json and remove the entry referencing the missing file.
  2. Delete the corresponding snapshot file from drizzle/meta/ if one exists.
  3. Re-run the workflow from the drizzle-kit generate step.

Always use --prefix=none when creating custom RLS migrations. Without it, Drizzle Kit adds a numeric or timestamp prefix to the filename, and the tenant-cli migrate -o path won’t match. See CLI & Migrations — Workflow for the correct steps.


better-tenant: No config found.

The CLI looks for configuration in this order:

  1. better-tenant.config.json in the current directory
  2. A "betterTenant" key in package.json

Fix: run init to create the config interactively, or create it manually:

better-tenant.config.json
{
"tenantTables": ["projects", "tasks"]
}
better-tenant: Invalid JSON in better-tenant.config.json: ...
better-tenant: config must have tenantTables (string[])

The config must be a valid JSON object with a tenantTables array of table name strings. Check for trailing commas, missing quotes, or a non-array value.

check requires --database-url or DATABASE_URL environment variable
seed requires --database-url or DATABASE_URL environment variable

Pass the URL via flag or environment variable. It must use a postgres:// or postgresql:// protocol:

Terminal window
# Via flag
npx @usebetterdev/tenant-cli check --database-url postgres://user:pass@localhost:5432/mydb
# Via environment variable
export DATABASE_URL=postgres://user:pass@localhost:5432/mydb
npx @usebetterdev/tenant-cli check