Appearance
Getting started
This page takes you from nothing to a working, enforced policy. The examples use Node with better-sqlite3 because it's terse, but every step is plain SQL — see your language guide for the exact calls in C, Rust, Bun, node:sqlite, or Python.
1. Build the extension
The extension is a single C file with no dependencies beyond SQLite. From the project root:
sh
make # produces dist/rls.so (dist/rls.dylib on macOS)You need a C compiler; the SQLite headers are vendored, so nothing else to install. On Windows: cl /LD src\rls.c /Ivendor /Fedist\rls.dll.
2. Load it and create some data
ts
import Database from "better-sqlite3";
const db = new Database("app.db");
db.loadExtension("./dist/rls.so");
db.exec(`
CREATE TABLE users(
id INTEGER PRIMARY KEY,
token TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'user'
);
CREATE TABLE documents(
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL,
title TEXT NOT NULL
);
INSERT INTO users(id, token, role) VALUES (1,'tok-alice','user'), (2,'tok-bob','user');
INSERT INTO documents(owner_id, title) VALUES (1,'Alice draft'), (2,'Bob notes');
`);Protected tables need a PRIMARY KEY
The write triggers identify rows by primary key, and the implicit rowid isn't visible through a view. Give every protected table an explicit PRIMARY KEY (an INTEGER PRIMARY KEY is perfect).
3. Set it all up with one call
rls_setup does the entire privileged setup — protect every table, wire up the user store, seal, and guard — in one declarative, correctly-ordered call. This is the recommended way; it removes the easy-to-misorder sequence of separate rls_protect / rls_config / rls_seal / rls_guard steps.
Policies use Postgres's vocabulary — using (who can see/act on a row) and check (what a written row may become), with bare column names. The owner shorthand writes the whole owner-scoped policy for you.
ts
db.prepare("SELECT rls_setup(?)").run(JSON.stringify({
adminRole: "admin", // this role bypasses every policy
ownerClaim: "user_id", // what the `owner` shorthand compares against
tables: [
// "you own it" — one line generates SELECT/INSERT/UPDATE/DELETE:
{ table: "documents", owner: "owner_id" },
// or spell out USING / WITH CHECK (bare columns, like Postgres):
{ table: "profiles", using: "1", check: "owner_id = rls_ctx('user_id')" },
],
// optional: let the extension resolve claims from a credential
resolver: "SELECT id AS user_id, role FROM _rls_users WHERE token = ?1",
seal: process.env.RLS_TOKEN, // gates re-elevation; omit only in dev
}));That's the whole setup. After it returns, the connection is guarded: base tables are unreachable, rls_protect is refused, and only scoped queries run. Full policy vocabulary is on Writing policies.
Postgres users will feel at home
using ≈ USING, check ≈ WITH CHECK, for ≈ FOR SELECT|INSERT|…, adminRole ≈ a bypass policy, and rls_ctx('x') ≈ current_setting('x'). Bare column names work in write policies just like Postgres — no OLD./NEW. bookkeeping. (Those still work if you want to be explicit.)
Prefer the individual calls (rls_protect etc.)? They're all still there and documented in the SQL API; rls_setup is sugar over them. On the Node side, the RlsConnection shim takes the same config object and calls rls_setup for you.
4. Serve queries as a user
ts
function setUser(claims: object) {
db.prepare("SELECT rls_set_context(?)").run(JSON.stringify(claims));
}
setUser({ user_id: 1, role: "user" });
db.prepare("SELECT title FROM documents").all(); // [{ title: 'Alice draft' }]
setUser({ user_id: 2, role: "user" });
db.prepare("SELECT title FROM documents").all(); // [{ title: 'Bob notes' }]
// or resolve from the store instead:
db.prepare("SELECT rls_auth(?)").run("tok-alice");
db.prepare("SELECT title FROM documents").all(); // [{ title: 'Alice draft' }]Alice sees Alice's rows, Bob sees Bob's — from the same SELECT * FROM documents, on the same connection, with nothing added to the query.
5. Fail-closed, by default
Forget to bind a user, and a protected query errors rather than leaking:
ts
db.prepare("SELECT rls_clear_context()").run();
db.prepare("SELECT * FROM documents").all();
// ^ throws: "RLS: no user context set"That's the whole loop. From here:
- Prefer one call per request? Node users get an
RlsConnectionshim. - Wiring this into a real app? See The user context for connection-pool guidance.
- Curious what's actually enforced? Read the security model.