Skip to content

Use cases

A few concrete shapes where row-level security in SQLite earns its keep, with the policy that expresses each.

Per-user data in a single-file app

A local-first or desktop app (Electron, Tauri, a CLI) that stores everyone's data in one SQLite file, but each profile should only see its own. Protect each table by owner and bind the active profile at startup:

json
{ "table": "notes",
  "select": "owner_id = rls_ctx('user_id')",
  "insert": "NEW.owner_id = rls_ctx('user_id')",
  "update": { "using": "OLD.owner_id = rls_ctx('user_id')",
              "check": "NEW.owner_id = rls_ctx('user_id')" },
  "delete": "OLD.owner_id = rls_ctx('user_id')" }

Even code paths you forget — an export routine, a sync job — stay scoped.

Multi-tenant SaaS on SQLite

Many small tenants, one database (or one per shard). A tenant_id on every row plus a tenant claim guarantees no query ever crosses tenants, no matter who writes it:

json
{ "table": "invoices",
  "select": "tenant_id = rls_ctx('tenant_id')",
  "insert": "NEW.tenant_id = rls_ctx('tenant_id')",
  "update": { "using": "OLD.tenant_id = rls_ctx('tenant_id')",
              "check": "NEW.tenant_id = rls_ctx('tenant_id')" },
  "delete": "OLD.tenant_id = rls_ctx('tenant_id')" }

Bind {"tenant_id": 7} from the tenant resolved off the request's subdomain or API key. A bug in one endpoint can't leak another tenant's invoices.

Role-based visibility

Regular users see their own rows; support staff and admins see everything. One expression, no separate "admin query path":

json
"select": "owner_id = rls_ctx('user_id') OR rls_ctx('role') IN ('admin','support')"

Because the same view serves both, you can't accidentally ship an admin screen that forgets the filter for normal users — there's only one filter.

Team / group membership

Rows belong to a team, and a user belongs to several. Carry the memberships as a claim array and test membership in the policy:

json
{ "table": "projects",
  "select": "EXISTS (SELECT 1 FROM json_each(rls_ctx('teams')) WHERE value = team)",
  "insert": "EXISTS (SELECT 1 FROM json_each(rls_ctx('teams')) WHERE value = NEW.team)" }

Bind {"user_id": 1, "teams": ["infra", "ml"]}. Membership changes are just different claims — no policy redeploy.

Sharing beyond ownership

Ownership is the default, but a policy can consult a share table for exceptions — documents you own or that have been shared with you:

json
"select": "owner_id = rls_ctx('user_id') OR id IN (SELECT doc_id FROM shares WHERE user_id = rls_ctx('user_id'))"

(Protect shares too, or keep it readable, depending on whether users should see who else a document is shared with.)

Defense-in-depth behind an ORM

Already filtering in your application or ORM? Row-level security makes that filter the belt and this the suspenders. If an ORM query is mis-scoped, or a raw query slips past the ORM, the database still refuses to return other users' rows. The failure mode changes from "silent leak" to "empty result or loud error."

A shared read-only catalog

Not everything is per-user. A reference table everyone reads but only privileged setup writes:

json
{ "table": "plans", "select": true }

Omitting the write fields makes it read-only to every guarded connection.


The common thread: the rule lives in the database, once, and holds for every path that reaches the table. See Writing policies to compose your own, or a language guide to wire it into your stack.

A loadable SQLite extension. No warranty; test against your own schema.