Skip to content

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 assigned

Likewise, 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 ACTION FKs and cascade explicitly through the views. Delete the children with DELETE 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 = OFF on 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 CASCADE across 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.

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