Skip to content

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

usingUSING, checkWITH CHECK, forFOR 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:

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