Skip to content

Troubleshooting

Tenant could not be resolved

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).

Common causes

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.localhostacme
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.

Customizing the error response

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 example)
createHonoMiddleware(tenant, {
onMissingTenant: (c) => c.json({ error: "Unknown workspace" }, 404),
});

RLS blocks all rows

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.

Run the check command first

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

Slug lookup fails silently

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

Connection pooling

Better 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

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

No cross-request leakage

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.


Context is undefined

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

Common causes

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. Better 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);
});

CLI errors

No config found

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"]
}

Invalid config

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.

Database URL required

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

Next steps