Skip to content

Node — better-sqlite3

better-sqlite3 is the most common synchronous SQLite driver for Node, and the one the project's optional shim targets. Because it's synchronous, the "bind context → run query" pair is atomic — no async can interleave between them.

Load and set up

ts
import Database from "better-sqlite3";

const db = new Database("app.db");
db.loadExtension("./dist/rls.so");        // .dylib on macOS, .dll on Windows

db.exec(`
  CREATE TABLE documents(id INTEGER PRIMARY KEY, owner_id INT NOT NULL, title TEXT);
  INSERT INTO documents(owner_id, title) VALUES (1,'Alice'), (2,'Bob');
`);

db.prepare("SELECT rls_protect(?)").run(JSON.stringify({
  table: "documents",
  select: "owner_id = rls_ctx('user_id') OR rls_ctx('role') = 'admin'",
  insert: "NEW.owner_id = rls_ctx('user_id')",
}));

db.prepare("SELECT rls_seal(?)").run(process.env.RLS_TOKEN);
db.prepare("SELECT rls_guard()").run();

Serve queries

ts
db.prepare("SELECT rls_set_context(?)").run(JSON.stringify({ user_id: 1, role: "user" }));
db.prepare("SELECT title FROM documents").all();   // [{ title: 'Alice' }]

The RlsConnection shim

The project ships an optional wrapper (src/index.ts) that folds context binding and the query into one call, and — importantly — arms the guard before returning the connection, so your application code never holds an unguarded or pre-guard handle.

ts
import Database from "better-sqlite3";
import { RlsConnection } from "sqlite-rls";

const rls = RlsConnection.attach(new Database("app.db"), {
  extensionPath: "./dist/rls.so",
  token: process.env.RLS_TOKEN!,
  setup(db) {                                  // runs privileged, before guarding
    db.prepare("SELECT rls_protect(?)").run(JSON.stringify(policy));
    db.prepare("SELECT rls_config('resolver', ?)").run(resolverSql);
  },
});

// per request — one call binds the context and runs the query:
rls.as({ user_id: 42, role: "user" }).all("SELECT * FROM documents");
rls.as({ user_id: 42 }).run("INSERT INTO documents(owner_id, title) VALUES (42, ?)", "New");
const id = rls.lastInsertRowid();              // rls_last_rowid(), the reliable one

// resolve claims from the configured resolver instead of passing them:
rls.auth(sessionToken).all("SELECT * FROM documents");

// run with no user (protected tables fail closed):
rls.as(null).all("SELECT * FROM documents");   // throws "no user context set"

Write results

Remember that changes is 0 for writes through a policy view (SQLite doesn't count trigger work). Use RETURNING, rls_last_rowid(), or a re-query to confirm:

ts
const rows = db.prepare(
  "UPDATE documents SET title = ? WHERE id = ? RETURNING title"
).all("v2", 1);

Connection pools

better-sqlite3 connections are cheap and synchronous, so most apps use one per worker. If you pool, do the load + rls_protect + rls_config + rls_seal + rls_guard when creating each connection, and bind the context per request. Never leave an unguarded connection in the pool — it's a privileged handle. The concurrency rule: keep the synchronous .as(claims).all(sql) pair intact, or give each concurrent request its own connection.

Notes

  • loadExtension throws if the file is missing or the build is for a different platform — point it at the dist/rls.* you built with make.
  • better-sqlite3 doesn't expose SQLite's authorizer to JS, which is exactly why enforcement lives inside the extension — you get it for free here.

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