Skip to content

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 from rls_ctx as their SQL type; arrays and objects come back as JSON text (use json_each / json_extract in the policy).
  • A missing claim makes rls_ctx return NULL, so a comparison like owner_id = rls_ctx('user_id') is NULL → 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 integer 1 — 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 error

A 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_guardrls_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.

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