Non-Tenant Queries
When and how to use the bare database connection for operations that intentionally bypass RLS.
Most code should use withTenantContext. This page covers the cases where the bare db connection is appropriate and the patterns for using it safely.
When Bare db Is Correct
| Use Case | Why RLS Is Skipped | Example |
|---|---|---|
| Migrations | Schema changes need superuser access | packages/db/src/migrate.ts |
| Seeds | Inserting data across multiple tenants | packages/db/src/seed.ts |
| Auth bootstrap | Better Auth manages sessions, accounts, and users globally | Better Auth internals |
| Health checks | SELECT 1 has no tenant context | checkDatabase() |
| User-scoped operations | PATs belong to a user, not a tenant | packages/db/src/pat-queries.ts |
| Organization resolution | Looking up which org a user belongs to (before tenant context is established) | packages/mcp/src/resolve-org.ts |
Import Pattern
// Explicit import from /client signals intentional RLS bypass
import { db } from "@repo/db/client";
The default @repo/db export does not include db. This is enforced at the package level -- if you try to import db from @repo/db, TypeScript will error.
User-Scoped Queries (PAT Example)
Personal Access Tokens are scoped to a user, not an organization. They live in a table without RLS policies (personal_access_token is listed as a non-tenant table in packages/db/CLAUDE.md).
The PAT query functions encapsulate bare db usage so that callers never need to import from @repo/db/client:
// packages/db/src/pat-queries.ts
import { db } from "./client";
import { personalAccessToken } from "./schema/index";
export async function listUserPATs(userId: string) {
return db
.select({
id: personalAccessToken.id,
name: personalAccessToken.name,
tokenPrefix: personalAccessToken.tokenPrefix,
lastUsedAt: personalAccessToken.lastUsedAt,
expiresAt: personalAccessToken.expiresAt,
createdAt: personalAccessToken.createdAt,
})
.from(personalAccessToken)
.where(and(eq(personalAccessToken.userId, userId), isNull(personalAccessToken.revokedAt)))
.orderBy(personalAccessToken.createdAt);
}
These functions are exported from @repo/db (the default path), so consumers call them without knowing they use the bare connection internally:
import { listUserPATs } from "@repo/db";
const tokens = await listUserPATs(userId);
This is the preferred pattern when you need bare db access -- encapsulate it in a function and export it from the package's public API.
Non-Tenant Tables
These tables intentionally skip RLS. See Schema Design for the full list.
user-- global user recordssession-- auth sessionsaccount-- OAuth account linksverification-- email/phone verification tokensorganization-- the tenant records themselves (metadata, not tenant-scoped data)ai_model,ai_model_pricing-- reference datapersonal_access_token-- user-scoped, not tenant-scoped
Queries against these tables can use either the bare db or a tenant-scoped transaction. When accessed through ctx.db (inside withTenantContext), they work normally because they have no RLS policies -- the authenticated role has full CRUD access via the ALTER DEFAULT PRIVILEGES grant.
Organization Resolution
The MCP server needs to determine which organization a user belongs to before it can call withTenantContext. This chicken-and-egg problem is solved by resolveOrganizationId:
// packages/mcp/src/resolve-org.ts
import { db } from "@repo/db/client";
export async function resolveOrganizationId(userId: string): Promise<string | null> {
// Looks up the user's active or default organization
// Uses bare db because no tenant context exists yet
}
This is one of the few places where bare db is used in application (non-infrastructure) code.
Testing with Bare db
The RLS integration tests use the bare connection to seed test data as the superuser, then switch to the authenticated role to exercise policies:
// packages/db/src/__tests__/rls.test.ts
async function asAuthenticatedTenant<T>(
tenantId: string,
fn: (tx: ReturnType<typeof drizzle>) => Promise<T>,
): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SET ROLE authenticated`);
await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`);
const result = await fn(tx as unknown as ReturnType<typeof drizzle>);
await tx.execute(sql`RESET ROLE`);
return result;
});
}
This differs from withTenantContext in one key way: it explicitly sets SET ROLE authenticated to simulate how a real authenticated role connection would behave, which makes the RLS policies fire. The production code relies on the policies being defined as TO authenticated and the session variables being set.
Safety Checklist
Before using db from @repo/db/client, verify:
- The table has no
organization_idcolumn, or you are doing infrastructure work (migration, seed) - The function is encapsulated and exported from
@repo/db(not leaking baredbto consumers) - User input is validated before being used in the query (no RLS safety net)
- The use case is listed in the "legitimate uses" comment in
client.ts