Troubleshooting
Tenant could not be resolved
better-tenant: tenant could not be resolved from requestThis 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:
# Wrong — header name doesn't matchcurl -H "X-Tenant: abc" http://localhost:3000/projects
# Correctcurl -H "x-tenant-id: abc" http://localhost:3000/projectsSubdomain not detected on localhost. The subdomain resolver needs at least 3 hostname segments. localhost and example.com don’t have a subdomain:
| Host | Extracted subdomain |
|---|---|
acme.app.com | acme |
acme.localhost | acme |
localhost | nothing — too few segments |
example.com | nothing — 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 segmentJWT 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 401createHonoMiddleware(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
npx @usebetterdev/tenant-cli check --database-url $DATABASE_URLThe check command runs 10+ validations and pinpoints the exact issue. Here’s what each failure means and how to fix it:
| Failure | Meaning | Fix |
|---|---|---|
tenants table not found | The tenants table doesn’t exist | Run 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 found | A table listed in tenantTables doesn’t exist in the database | Create the table first, then re-run the migration |
column tenant_id not found | The table is missing the tenant_id column | Re-run the migration or add: ALTER TABLE <name> ADD COLUMN tenant_id UUID NOT NULL REFERENCES tenants(id) |
ROW LEVEL SECURITY not enabled | RLS is not turned on for this table | Run: ALTER TABLE <name> ENABLE ROW LEVEL SECURITY |
FORCE ROW LEVEL SECURITY not enabled | RLS can be bypassed by the table owner role | Run: ALTER TABLE <name> FORCE ROW LEVEL SECURITY |
no RLS policy found | No policy exists on this table | Re-run the migration to generate the policy |
policy missing USING expression | The policy doesn’t filter reads | Re-create the policy with a USING clause |
policy missing WITH CHECK expression | The policy doesn’t validate writes | Re-create the policy with a WITH CHECK clause |
policy should allow bypass_rls for runAsSystem | The policy doesn’t include the app.bypass_rls escape hatch | Re-create the policy with OR current_setting('app.bypass_rls', true) = 'true' in both clauses |
trigger set_tenant_id_trigger not found | The auto-populate trigger is missing | Re-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:
npx @usebetterdev/tenant-cli seed --name "Acme Corp" --slug "acme" --database-url $DATABASE_URLConnection 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.
pool_mode = transaction # correct — SET LOCAL is cleared per transaction# pool_mode = session # wrong — session variables persist across requestsNo 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 scopeapp.get("/projects", async (c) => { const db = tenant.getDatabase(); // returns the scoped database});
// This does NOT work — outside any scopeconst db = tenant.getDatabase(); // undefinedCalling 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:
better-tenant.config.jsonin the current directory- A
"betterTenant"key inpackage.json
Fix: run init to create the config interactively, or create it manually:
{ "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 variableseed requires --database-url or DATABASE_URL environment variablePass the URL via flag or environment variable. It must use a postgres:// or postgresql:// protocol:
# Via flagnpx @usebetterdev/tenant-cli check --database-url postgres://user:pass@localhost:5432/mydb
# Via environment variableexport DATABASE_URL=postgres://user:pass@localhost:5432/mydbnpx @usebetterdev/tenant-cli checkNext steps
- CLI & Migrations — all CLI commands and workflows
- Configuration — resolver strategies and tenant API
- Architecture — how RLS and session variables work under the hood