When a single RLS policy bug exposes all tenant data — How to reduce the blast radius with Turso libSQL's DB-per-tenant and Drizzle ORM dynamic connections
When most teams first design a multi-tenant SaaS, they start with RLS (Row Level Security). The reason is clear: you only need to run one database, you can implement tenant_id-based row-level access control using PostgreSQL's policy language, and platforms like Supabase push this pattern as the default.
But RLS carries a quiet danger. Consider the following code.
-- The policy is written correctly
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant'));// Setting the session variable in the ORM layer
async function getInvoices(tenantId: string) {
// Using SET (session level) means when the connection pool reuses this connection,
// the previous tenant value persists
await pool.query(`SET app.current_tenant = '${tenantId}'`);
return pool.query('SELECT * FROM invoices');
}Without SET LOCAL, the setting persists in the session. When the connection pool reuses the same connection for the next request, app.current_tenant still holds the previous tenant's value. A request for tenant_B returns tenant_A's data. And that's not all. ENABLE ROW LEVEL SECURITY alone lets the table owner bypass RLS. You need to add FORCE ROW LEVEL SECURITY to apply policies to the owner as well — omit that one line, and an ORM connected with DB owner privileges can access every tenant's data.
A policy logic bug, a missing SET LOCAL, an unexpected query generated by the ORM — any of these paths leads to all tenants' data being exposed at once within a single DB. In security terms, the "blast radius" is enormous.
Turso libSQL's DB-per-tenant architecture eliminates this particular threat path at the structural level. There are no RLS policies, so there are no policy bugs. However, the threat doesn't disappear — it moves to the tenant→DB routing layer. A routing bug or authentication error can result in connecting to the wrong tenant's DB. Understanding this tradeoff precisely is what matters.
In this post, we'll cover the decision criteria for three isolation strategies, code for automating tenant onboarding with the Turso Platform API, and the pattern for dynamically connecting a Drizzle ORM instance per request. We'll also cover a migration runner script that manually iterates over migrations, since Multi-DB Schemas — as of the time of writing — has been deprecated for new users.
Core Concepts
libSQL and Turso: SQLite for the Cloud
libSQL is a C-based open-source fork of SQLite maintained by Turso. SQLite itself is open source but does not accept external contributions, so Turso created a fork to introduce an open contribution model. It preserves SQLite's core model of a single file as a database while adding features like remote access (WebSocket/HTTP), embedded replicas, vector search, and encryption.
The libSQL server component (sqld) is implemented in Rust, and a full Rust reimplementation of SQLite is being developed as a separate project called Limbo (now Turso Database). All code examples in this post use the @libsql/client SDK, which is based on C-based libSQL.
Embedded replicas are an important concept in production. By replicating a remote Turso database (primary) to a local file on the application server, reads are served from the local file at microsecond latency, while writes are sent to the primary. There is a few-millisecond delay before writes sync to the local replica, so read consistency immediately after a write requires care.
Turso is an edge database platform built on top of libSQL. Thanks to the file-based structure, idle databases don't occupy a process — they only incur storage costs. As of the time of writing, the Developer plan ($4.99/month) offers unlimited databases, with storage billed at $0.75/GB. Pricing may change, so check the official Pricing page.
Comparing 3 Multi-Tenant Isolation Strategies
| Strategy | Isolation Level | Blast Radius | Operational Complexity | Cost |
|---|---|---|---|---|
| RLS | Logical | All tenants | Low | Low |
| Schema-per-tenant | Logical | Within the same instance | Medium | Low–Medium |
| DB-per-tenant | Physical | Affected tenant only (excluding routing layer) | High | Reversed by Turso |
DB-per-tenant was impractical in traditional PostgreSQL environments due to prohibitive operational complexity. What Turso has flipped is exactly this cost and operational structure.
Decision Criteria
Let's first look at the decision flow for choosing a strategy.
There is a reason to recommend Schema-per-tenant to teams who lack the expertise to manage RLS. RLS failure points are policy language errors — subtle and with a wide blast radius. Schema-per-tenant's operational burden is schema provisioning (mechanical and automatable) rather than policy language. When the number of tenants is small, this overhead is manageable, and since isolation is within the same instance, the impact of mistakes is more limited than RLS policy bugs. Once tenants exceed several hundred, schema management also becomes burdensome, making DB-per-tenant the right fit.
Here are specific situations where you should choose DB-per-tenant:
- When you need to physically limit the scope of a data breach, as in financial services, healthcare, or B2B SaaS
- GDPR/HIPAA data residency requirements — EU tenants in European regions, US tenants in US regions
- When you need independent per-tenant backup/restore or different schema structures per tenant
- When minimizing read latency is a core requirement in edge deployment environments
Practical Implementation
1. Automating Tenant Onboarding — Clerk Webhook + Turso Platform API
This is the pattern for automatically provisioning a dedicated database when a user signs up.
The following code targets Next.js App Router, with Svix signature verification included inline. Skipping this step means accepting unverified webhooks, which can trigger arbitrary DB provisioning requests.
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { createClient } from '@tursodatabase/api';
const turso = createClient({
org: process.env.TURSO_ORG!,
token: process.env.TURSO_API_TOKEN!,
});
export async function POST(req: Request) {
const payload = await req.text();
const headers = {
'svix-id': req.headers.get('svix-id') ?? '',
'svix-timestamp': req.headers.get('svix-timestamp') ?? '',
'svix-signature': req.headers.get('svix-signature') ?? '',
};
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
let event: ReturnType<typeof wh.verify>;
try {
event = wh.verify(payload, headers) as { type: string; data: { id: string } };
} catch {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'user.created') {
const userId = event.data.id;
const dbName = `tenant-${userId.replace(/_/g, '-')}`;
const dbResult = await turso.databases.create(dbName, { group: 'default' });
// Use the hostname returned by databases.create() directly.
// Building the URL from a string template breaks consistency if naming conventions change.
const hostname = dbResult.hostname;
const tokenResult = await turso.databases.createToken(dbName);
// Store the hostname and token together
await storeCredentialsForTenant(userId, { hostname, authToken: tokenResult.jwt });
}
return Response.json({ received: true });
}storeCredentialsForTenant can be implemented however fits your team — Vercel KV, Upstash Redis, a separate meta DB, etc. Don't hardcode tokens in environment variables. The moment you exceed 50 tenants, environment variable management becomes unworkable.
2. Dynamically Creating a Drizzle Instance Per Request
This is the biggest difference from RLS. After identifying the tenant from the request, you create a new instance connected to that tenant's database on the fly.
// lib/db.ts
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
// In long-running Node.js servers, instances are reused for the process lifetime.
// Serverless (Vercel Functions, Lambda): the cache persists across warm instances,
// but is re-initialized on every cold start — still prevents duplicate connections within a request.
// Edge environments like Cloudflare Workers: no state between requests,
// so this Map cache won't work. Use external KV or the waitUntil pattern instead.
const instanceCache = new Map<string, ReturnType<typeof drizzle>>();
export async function getTenantDB(tenantId: string) {
if (instanceCache.has(tenantId)) {
return instanceCache.get(tenantId)!;
}
const { hostname, authToken } = await getCredentialsForTenant(tenantId);
const client = createClient({
url: `libsql://${hostname}`,
authToken,
});
const db = drizzle(client, { schema });
instanceCache.set(tenantId, db);
return db;
}Here is how you use this in an actual API handler.
// app/api/invoices/route.ts
import { getTenantDB } from '@/lib/db';
import { invoices } from '@/lib/schema';
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId } = await auth(); // auth() is async in App Router
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
// userId is the tenant ID — no tenant_id filter needed because DB isolation handles it
const db = await getTenantDB(userId);
const result = await db.select().from(invoices);
return Response.json(result);
}There is no filter like where(eq(invoices.tenantId, userId)). The database itself belongs to that tenant. No RLS policies, no tenant_id column needed. However, if the wrong userId is passed to getTenantDB(userId), or if there is a bug in the authentication layer, it will connect to the wrong tenant's database. This is the threat point that has moved to the routing layer. The authentication middleware is the gatekeeper of this layer.
3. Defining the Drizzle Schema
Even with per-tenant databases, the schema definition itself is identical across all tenants.
// lib/schema.ts
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
export const invoices = sqliteTable('invoices', {
id: text('id').primaryKey(),
amount: real('amount').notNull(),
status: text('status', { enum: ['pending', 'paid', 'overdue'] }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const customers = sqliteTable('customers', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
});No tenant_id column. The schema becomes significantly simpler.
4. Building Your Own Multi-Tenant Migration Runner
As of the time of writing, the Multi-DB Schemas feature has been deprecated for new Turso users. This feature used to propagate a migration run once on a parent schema DB to all child DBs — now you have to iterate manually. Automating this with the Platform API makes it manageable in practice.
If you have thousands of tenants, you need to cap the concurrency. The code below uses p-limit to maintain a concurrency of 20.
// scripts/migrate-all-tenants.ts
import { createClient as createTursoApiClient } from '@tursodatabase/api';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import pLimit from 'p-limit';
const turso = createTursoApiClient({
org: process.env.TURSO_ORG!,
token: process.env.TURSO_API_TOKEN!,
});
async function migrateAllTenants() {
const databases = await turso.databases.list();
// Filter to tenant DBs only — meta DBs or other-purpose DBs may be mixed in
const tenantDbs = databases.filter((db) => db.name.startsWith('tenant-'));
console.log(`Running migrations on ${tenantDbs.length} tenant databases.`);
const limit = pLimit(20); // cap at 20 concurrent
const results = await Promise.allSettled(
tenantDbs.map((db) =>
limit(async () => {
const tokenResult = await turso.databases.createToken(db.name);
// Use the hostname returned by databases.list() as-is
const client = createClient({
url: `libsql://${db.hostname}`,
authToken: tokenResult.jwt,
});
try {
const drizzleDb = drizzle(client);
await migrate(drizzleDb, { migrationsFolder: './drizzle' });
} finally {
// Close the connection regardless of success or failure
client.close();
}
return db.name;
})
)
);
// Using Promise.allSettled so one tenant's failure doesn't halt the rest
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`✅ ${result.value} done`);
} else {
console.error(`❌ ${tenantDbs[index].name} failed:`, result.reason);
}
});
}
migrateAllTenants().catch(console.error);Pros and Cons
Advantages
| Item | What It Actually Means |
|---|---|
| Eliminates RLS policy threat path | RLS policy bugs, missing SET LOCAL, owner bypass — this path simply doesn't exist |
| Limits data breach scope | Even if a breach occurs, only that tenant's DB file is affected (routing layer bugs are separate) |
| Eliminates performance noise | One tenant's heavy queries don't affect other tenants' response times |
| Simplifies compliance | Per-tenant data region control via Database Groups; individual deletion and auditing possible |
| Low-cost many databases | Idle DBs incur only storage costs; DB count itself is not a billing factor |
| Simpler queries | No tenant_id column, RLS policies, or permission logic needed |
| Ultra-low read latency | With embedded replicas, reads come directly from the local file at microsecond latency |
Database Groups are an important concept here. A Turso Group is the unit for deploying a set of databases across multiple regions. Create a group for EU tenants in the eu-west region and a group for US tenants in us-east, and databases in each group keep their data in that region. GDPR data residency requirements can be satisfied simply by specifying the right group at database creation time.
Disadvantages
| Item | Actual Impact |
|---|---|
| Routing layer becomes a new threat point | Auth bug or wrong tenantId → connection to the wrong DB; structurally similar risk to RLS |
| Single-writer model | SQLite's inherited limitation can bottleneck write-heavy workloads compared to PostgreSQL |
| Manual migration management | After Multi-DB Schemas deprecation, you must iterate across all tenant DBs |
| Cross-tenant aggregation is difficult | Increased complexity for fleet-wide stats; large-scale analytics requires a separate solution |
| Replica sync delay | A few ms delay before writes sync to embedded replicas; be careful with read consistency immediately after writes |
| Feature maturity | No Stored Procedures or LISTEN/NOTIFY; not suitable for OLAP |
| Platform dependency | Lock-in to the Turso ecosystem; self-hosting carries operational complexity |
For cross-tenant aggregation, Multi-Database Attach is a partial solution. Similar to SQLite's ATTACH DATABASE, Turso allows you to attach multiple remote DBs into a single query context. It works for aggregating across a small number of tenants, but is not suitable for aggregating across thousands. If complex cross-tenant analysis is a core requirement for your admin dashboard, planning a separate analytics pipeline is the realistic path.
Common Pitfalls in Practice
Reading immediately after a write
When using embedded replicas, reading the same data within a few milliseconds of writing it may return a stale value. For critical flows, either wait for explicit synchronization with a sync() call, or handle both the write and the subsequent read over the same connection (the remote primary).
Managing tenant tokens via environment variables
With 10 tenants, environment variables like TURSO_TOKEN_tenant123 can work. The moment you exceed 50, management becomes impossible. Start with a structure that stores tokens in a KV store or Vault from day one.
Migration drift
With hundreds of tenants, a new migration deployment can fail to apply to some tenant DBs. Include the migration runner in your CI/CD pipeline, and track each tenant's migration state in a meta DB for long-term safety.
Underestimating cross-tenant analytics requirements
Projects often start with "we just need a total revenue sum in the admin dashboard," only to see analytics requirements grow more complex over time. If complex cross-tenant aggregation is a core feature, evaluate this before choosing DB-per-tenant.
When to Choose DB-per-Tenant
Turso libSQL's DB-per-tenant architecture eliminates specific threat paths that RLS cannot address at the structural level. RLS policy bugs, missing SET LOCAL, owner bypass — the possibility of these mistakes simply doesn't exist. In exchange, the threat point moves to the tenant→DB routing layer, making the reliability of the authentication middleware the critical factor.
If you have write-heavy workloads, complex cross-tenant analytics needs, or haven't yet built migration automation capabilities, RLS or Supabase remains a realistic choice. What matters is choosing with a clear understanding of the tradeoffs.
If you're starting out, this sequence should help:
Step 1 — Verify local integration
Install @libsql/client and drizzle-orm, then connect to a local SQLite file with file:./test.db to confirm your schema and queries work correctly before doing anything else.
Step 2 — Manually create 2–3 tenant DBs on the Turso Developer plan Walking through DB creation and token issuance via the Platform API by hand once makes the onboarding automation code much easier to envision. Vercel's "Turso Per User Starter" template demonstrates this flow best.
Step 3 — Wire the migration runner into CI/CD It's best to include a full-tenant migration runner in your deployment pipeline from the early stages of development. Adding it after you have hundreds of tenants makes the first run itself a major event.
References
- Turso official multi-tenancy page
- Turso blog: Database Per Tenant Architectures Get Production Friendly Improvements
- Turso blog: Multi-Tenant E-Commerce Architecture with Turso
- Turso blog: Creating a multitenant SaaS service with Turso, Remix, and Drizzle
- Turso blog: Introducing the Turso Per-User Starter
- Turso blog: Working with Clerk and per-user databases
- Turso official docs: libSQL overview
- Turso official docs: Platform API
- Turso official docs: Embedded Replicas
- Drizzle ORM official docs: Drizzle with Turso
- Drizzle ORM official docs: Connect Turso Cloud
- Turso official docs: Drizzle ORM integration
- GitHub: turso-api-client-ts
- GitHub: libSQL open-source repository
- Vercel template: Turso Per User Starter
- DEV Community: Distributed SQLite — Why LibSQL and Turso are the New Standard in 2026
- aliasghar.me: Multi-tenant SaaS — RLS vs schema-per-tenant vs database-per-tenant