Appearance
Caveats & sharp edges
None of these are bugs — they're consequences of implementing RLS with views and triggers. Know them and they won't bite.
Writes report changes = 0
SQLite doesn't count work done inside INSTEAD OF triggers, so a driver's changes / "rows affected" is 0 for INSERT/UPDATE/DELETE on a protected table, even when rows changed. To confirm a write, use RETURNING, check total_changes(), or re-query. This is cosmetic — the write really happens.
last_insert_rowid() resets after view inserts
SQLite reverts last_insert_rowid() when an INSTEAD OF trigger ends, so it's useless for inserts through a policy view. Use rls_last_rowid() instead, which the extension captures inside the trigger:
sql
INSERT INTO documents(owner_id, title) VALUES (42, 'x');
SELECT rls_last_rowid(); -- the id just assignedLikewise, RETURNING id on an auto-increment insert returns NULL through the view, for the same reason — read rls_last_rowid() after the insert.
Claim type coercion
Claims are compared with SQLite's normal type affinity. A claim of "1" (string) compared against an INTEGER column matches integer 1 — the same row the number would match, never a different one. It does not "fail closed" on a type mismatch, but it also can't over-match. For strict typing, cast in the policy (CAST(rls_ctx('user_id') AS INTEGER)) or normalize types in your resolver.
Column DEFAULTs on insert
The generated INSERT trigger applies column DEFAULTs (via coalesce), so omitted columns behave normally. One subtlety: inserting an explicit NULL into a column that has a DEFAULT yields the default, not NULL.
Foreign keys & cascading actions
Plain foreign-key integrity works normally under RLS: RESTRICT / NO ACTION checks and INSERT-time FK checks run as part of the sanctioned view/trigger, so they're allowed and orphans are still prevented.
What does not work is a foreign-key action that writes a protected table — ON DELETE/UPDATE CASCADE, SET NULL, SET DEFAULT. SQLite performs that cascade write against the _rls_<child> base table with no caller attribution (caller = NULL), which is byte-for-byte identical to a direct DELETE FROM _rls_child attack — so the guard denies it (this is the same protection as the transfer-optimization fix; it can't be relaxed without reopening a real bypass). The delete then fails with "not authorized".
This is the same tension Postgres has between FKs and RLS. Options, cleanest first:
- Use
RESTRICT/NO ACTIONFKs and cascade explicitly through the views. Delete the children withDELETE FROM child WHERE parent_id = ?(which goes through the child's policy, so it's both scoped and allowed), then delete the parent. Integrity and RLS are both preserved. - Let your app/ORM maintain relational integrity and run
PRAGMA foreign_keys = OFFon the guarded connection. This is what a MikroORM-style layer that already tracks relations does; it trades DB-level FK enforcement for app-level. - Avoid
ON DELETE CASCADEacross protected tables.
Separately, an ON DELETE CASCADE from an unprotected parent into a protected child would delete at the base layer, bypassing the child's policy — so protect the parent too (or model the cascade in policy).
Protect tables before creating dependent objects
Renaming a table to _rls_<table> rewrites references inside existing views and triggers to point at the base table — which would bypass the policy. rls_protect returns a warning listing any such objects. Order your setup so rls_protect runs before you create views/triggers that reference the table.
Guarded connections can't create TEMP tables
All DDL is denied once guarded, including CREATE TEMP TABLE. Create any scratch schema before rls_guard(), or on a separate privileged connection.
Never use rls_ctx() in schema
rls_ctx is only constant within a single statement; its value changes between queries. Using it in an index, generated column, or CHECK constraint is meaningless and unsupported. Its intended homes are the view/trigger definitions the extension generates for you.
One authorizer per connection
The extension installs SQLite's authorizer to enforce the guard. Installing your own authorizer on the same connection displaces it. In Rust, that means don't call conn.authorizer(...) on a guarded rusqlite connection — see the rusqlite guide.
Observable metadata
By design, a guarded connection can learn a base table's row count (via count(*)) and can read policy logic from sqlite_master. Neither exposes row data. See the security model for the full boundary.