Skip to content

Security model

Row-level security is a security feature, so it's worth being precise about what it defends and what it doesn't. This page is the honest version; there are no surprises hiding in the reference.

What it protects against

The boundary is a guarded connection — one where rls_guard() has run (and, in production, rls_seal(token) before it). On such a connection, sqlite-rls stops:

  • reading row data from a protected table that the policy hides — including via subqueries, CTEs, UNION, joins, and every table-name quoting trick;
  • writing (insert/update/delete) rows the policy forbids;
  • reaching the _rls_* base tables directly;
  • escalating out: DDL, ATTACH, loading another extension, or pragma writes that would weaken enforcement;
  • disarming the guard without the seal token;
  • using a leaky or planted view/trigger that references a base table as a back door.

And it fails closed: no context bound, or the extension not loaded at all, means protected queries error rather than return everything.

What it does not protect against

This is the same trust model as PostgreSQL's RLS with current_setting(), and it's important to internalize:

An attacker who can run arbitrary SQL on the connection can call rls_set_context() and declare themselves anyone — including an admin.

Row-level security scopes what an authenticated principal may see. By default it is not a sandbox for executing untrusted SQL — but rls_lock() provides one: while locked, the authorizer refuses identity/privilege changes and all writes, so you can run a client-supplied read-only SELECT under a fixed identity and have RLS still scope it (bind context → rls_lock() → run → rls_unlock(token)). Outside that locked pattern, the practical consequences of "arbitrary SQL" stand:

  • Keep the seal token secret. It gates re-elevation to privileged mode. Anything that can read it, or run SQL you didn't write, is outside the boundary.
  • Don't hand raw SQL execution to untrusted users and expect RLS to contain it. RLS protects your code from its own bugs — the forgotten WHERE, the mis-scoped ORM call, an injection in one query — which is what it's for.

Two things are also observable by design: the row count of a base table (via count(*)), and the policy logic itself (it's visible in sqlite_master). Neither exposes row data.

Why you can trust the enforcement

The enforcement was red-teamed — an adversarial test suite (test/redteam.ts) plays an attacker with arbitrary SQL on a guarded connection and tries every bypass we could think of. Two real holes were found during that work and fixed:

  1. Leaky/planted schema objects. The authorizer originally trusted any view or trigger that touched a base table. A view like CREATE VIEW leak AS SELECT * FROM _rls_docs, created before the guard, could be read as a back door. Fixed: at guard time the extension snapshots exactly the view and triggers it generated and trusts only those — any other object referencing a base table is denied.

  2. Pre-guard prepared statements. SQLite authorizes at prepare time, so a statement compiled while privileged kept its access after guarding. Fixed: rls_guard() bumps the schema cookie, forcing SQLite to re-authorize cached statements; a pre-guard statement is then denied.

Both fixes ship with regression tests. The full matrix — quoting tricks, subquery smuggling, DDL/ATTACH/pragma escalation, writable_schema edits, resolver injection, unguard-without-token, reconnect to a persisted database — is documented in the project's SECURITY.md.

Operational guidance

  • Seal in production. rls_seal(token) before rls_guard(), with a token only your setup code knows. Without it, any code on the connection could call rls_unguard().
  • Guard before serving. Every connection should be guarded before it handles a request. On Node, the RlsConnection shim guards inside attach() so application code never holds an unguarded or pre-guard handle.
  • Protect parents too. An ON DELETE CASCADE from an unprotected parent into a protected child deletes at the base layer, bypassing the child's delete policy. Protect both, or model the cascade in policy.
  • One authorizer per connection. The extension owns SQLite's authorizer slot; don't install another on the same connection (this matters most in Rust — see the rusqlite guide).

For the exact function-by-function behavior, continue to the SQL API reference.

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