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.