Appearance
The user context
The "context" is the set of claims describing who is making the current request — user_id, role, tenant_id, and so on. Policies read it through rls_ctx('key'). This page is about getting the right claims bound at the right time.
Per query, not per connection
The context is bound with a SQL call and stays until you change it:
sql
SELECT rls_set_context('{"user_id": 42, "role": "user"}');Because a connection runs one statement at a time, binding the context immediately before a query gives exact per-query identity. This is the core of the design — the same connection can serve different users on consecutive queries, even inside one transaction:
ts
const tx = db.transaction(() => {
db.prepare("SELECT rls_set_context(?)").run(JSON.stringify({ user_id: 1 }));
db.prepare("INSERT INTO documents(owner_id, title) VALUES (1, 'a')").run();
db.prepare("SELECT rls_set_context(?)").run(JSON.stringify({ user_id: 2 }));
db.prepare("INSERT INTO documents(owner_id, title) VALUES (2, 'b')").run();
});
tx();That's why a connection pool works cleanly: you don't need a connection per user, just the right context bound before each query.
Two ways to get claims
You already have them
If your web layer verified a JWT or session and already holds the user's id and role, just pass them:
sql
SELECT rls_set_context('{"user_id": 42, "role": "user", "tenant_id": 7}');This is the fast path — no database lookup, the claims flow straight from your auth middleware into the policy.
Let the extension look them up
Configure a resolver query once, then hand the extension a credential and let it populate the claims:
sql
-- setup (privileged):
SELECT rls_config('resolver',
'SELECT id AS user_id, role, tenant_id FROM _rls_users WHERE token = ?1');
-- per request:
SELECT rls_auth('the-session-token');The resolver runs with the extension's own privileges, so it can read the _rls_users base table directly. Its result columns become the claims — name them as the keys your policies expect (AS user_id, etc.). If the resolver returns no row, rls_auth raises an authentication error and leaves the context unbound (fail closed).
The resolver's parameters are bound, never string-concatenated, so a hostile credential can't inject SQL.
Claim values and types
- Claims can be strings, numbers, booleans,
null, or nested JSON. Scalars come back fromrls_ctxas their SQL type; arrays and objects come back as JSON text (usejson_each/json_extractin the policy). - A missing claim makes
rls_ctxreturnNULL, so a comparison likeowner_id = rls_ctx('user_id')isNULL→ not true → the row is hidden. Absence fails closed. - Comparisons follow SQLite's normal type affinity. A claim of
"1"(string) compared against an integer column matches integer1— the same row the number would match, never a different one. If you need strict typing, cast in the policy or normalize in your resolver. (See caveats.)
Clearing and fail-closed
sql
SELECT rls_clear_context(); -- unbind; protected queries now errorA guarded connection with no context bound errors on protected tables (RLS: no user context set) instead of returning rows. This is deliberate: a bug that forgets to set the user surfaces as a loud failure, not a silent data leak.
In a connection pool
Split the work by lifetime:
| Do once, per connection (pool factory) | Do per query / per request |
|---|---|
load extension, rls_protect, rls_config, rls_seal, rls_guard | rls_set_context or rls_auth, then your SQL |
Guard every connection before it can serve a request — never leave an unguarded connection in the pool, since that's a privileged handle. If you're on Node, the RlsConnection shim folds "bind context → run query" into a single call so it can't be forgotten.