Stop retyping the same RLS policy
Enter a table name and a tenant column, get the exact CREATE POLICY SQL and a Jest test that proves isolation actually works. No signup, nothing leaves your browser.
How RLSBuilder works
RLSBuilder is a string template, not a database connection. Nothing you type reaches a server.
- 1
You enter a table name and tenant column
The table name is whatever tenant-scoped table you are securing (invoices, documents, projects). The tenant column is the column that identifies which tenant a row belongs to, usually tenant_id.
- 2
You pick a tenant ID type and isolation strategy
Tenant ID type (uuid, integer, or text) controls the cast applied when comparing the column against the session variable. Isolation strategy decides whether tenancy is enforced through a session variable your application sets, or through a PostgreSQL role that maps one-to-one with a tenant.
- 3
You select which operations need a policy
SELECT, INSERT, UPDATE, and DELETE are checked by default. Uncheck any your table does not need, for example a table that is insert-only from the application and never updated in place.
- 4
The SQL panel renders on every keystroke
ALTER TABLE ... ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY come first, followed by one CREATE POLICY per selected operation. UPDATE always gets both a USING and a WITH CHECK clause, the split most hand-written policies get wrong.
- 5
The Jest panel renders alongside it
For every selected operation, a matching test asserts that a second tenant cannot read, write, or destroy the first tenant's row. A bonus regression test checks that your application role does not have BYPASSRLS.
- 6
You copy both panels into your project
Paste the SQL into a migration, and the test below your existing Jest setup. Fill in the TODO comments (your seed helper and a real test database connection) and run it in CI.
What each generated policy does
Four operations, four different failure modes if a policy is missing or incomplete. Here is what each one actually blocks, and the mistake it exists to catch.
Filters which existing rows a query can return. Without it, any authenticated connection sees every tenant's rows in a plain SELECT * FROM table.
-- Tenant B runs this and gets zero rows back, not an error
SELECT * FROM invoices WHERE tenant_id = 'tenant-a';Uses WITH CHECK, not USING, because there is no existing row to filter yet, only the row about to be created. Without it, tenant B can insert a row and simply label it as tenant A's.
-- Rejected with a row-level security error, not silently ignored
INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-a', 500);The only operation that needs both clauses. USING controls which existing rows can be targeted; WITH CHECK controls what the row is allowed to become. A policy with only USING lets a tenant update their own row into someone else's tenant_id.
-- Blocked by WITH CHECK even though USING allowed targeting the row
UPDATE invoices SET tenant_id = 'tenant-a' WHERE tenant_id = 'tenant-b';Filters which rows a DELETE statement can remove. Without it, a broad DELETE FROM invoices WHERE amount > 0 run by any tenant clears every tenant's data, not just its own.
-- Only rows matching the caller's own tenant are ever deleted
DELETE FROM invoices WHERE amount > 0;Policy SQL syntax reference
Every generated policy follows the same shape. Knowing the parts helps you edit the output by hand once your policy needs logic beyond a single tenant column.
CREATE POLICY invoices_update_tenant_isolation ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- | | |
-- | | +-- cast matches your chosen tenant ID type
-- | +-- current_setting reads the session variable your app sets per request
-- +-- USING: which existing rows this policy applies to
-- WITH CHECK: what a resulting row is allowed to look like-- Inside a transaction, scoped to that transaction only
SET LOCAL app.tenant_id = '3f1b2c4a-...';
-- current_setting() with the second argument true returns NULL
-- instead of erroring if the variable was never set
SELECT current_setting('app.tenant_id', true);-- FORCE is required or the table owner bypasses every policy above
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
-- A cast mismatch fails at query time, not at CREATE POLICY time
-- if tenant_id is integer but the session variable holds a UUID string
tenant_id = current_setting('app.tenant_id')::integer -- errors on a UUID value
-- Superusers ALWAYS bypass RLS, FORCE included. Never test as postgres.When to use RLSBuilder
Each row maps a real situation to what to type into the tool.
| Situation | What to enter | What to check |
|---|---|---|
| Adding RLS to a brand new tenant-scoped table | The new table's name and its tenant_id column | All four operations, unless the table is truly read-only from the app |
| Auditing an existing policy that only has USING | The table, plus UPDATE and INSERT specifically | Compare the generated WITH CHECK clause against what is actually deployed |
| Writing the Jest regression suite before a PR review | Every tenant-scoped table touched in the PR | The generated test file, paste-ready into your existing suite |
| Security review comparing policies against a baseline | Each table under review, one at a time | Whether the deployed policy matches what a correct one should look like |
| Deciding between session variable and role-per-tenant | The same table with both strategies selected in turn | How the SQL and test shape differ, before committing to one |
Frequently Asked Questions
What does RLSBuilder do?
RLSBuilder generates PostgreSQL Row-Level Security policy SQL and a matching Jest test file from three inputs: a table name, a tenant/scope column, and the operations you want covered (SELECT, INSERT, UPDATE, DELETE). It outputs the exact CREATE POLICY statements with correctly split USING and WITH CHECK clauses, plus a ready-to-paste Jest suite that proves tenant isolation actually holds, not just that the SQL is syntactically valid.
Everything runs client-side. There is no database connection, no account, and no server round-trip.
How is this different from writing the policy by hand?
| RLSBuilder | Writing it by hand | |
|---|---|---|
| Time per table | Under a minute | 5-15 minutes, more if you forget WITH CHECK |
| WITH CHECK on UPDATE | Always included | Frequently forgotten, the most common RLS bug |
| Matching Jest test | Generated alongside the SQL | Usually skipped or written later, if ever |
| Consistency across tables | Same shape every time | Drifts as different people copy-paste old policies |
The SQL itself is not hard to write once. The value is in not retyping the same boilerplate for the fifteenth tenant-scoped table, and in never shipping a policy without the test that proves it works.
How is this different from an AI SQL generator?
General-purpose AI SQL generators (ChatGPT, AI2sql, and similar) produce policy SQL from a prompt, but output is inconsistent between runs, usually requires an account, and rarely generates a matching test in the same shape twice.
RLSBuilder is a fixed template, not a prompt. The same three inputs always produce the same SQL and the same test structure, which matters when you are generating policies for a dozen tables and want them to look like they came from the same hand.
Does RLSBuilder send my table or schema anywhere?
No. The table name, tenant column, and selected operations are string-templated into SQL and JavaScript entirely in your browser. Nothing is sent to a server, logged, or stored. You can use real internal table and column names without exposing your schema to a third party.
How do I use the generated Jest test in my own suite?
Paste the generated test below your own test setup file, then fill in the two TODO comments: a seed helper that inserts a row for the row-level fixture, and a testPool / adminPool connection to a real PostgreSQL instance (a postgres service container in CI works well). The generated test assumes a queryAsTenant helper, which is included at the top of the output.
test('tenant B cannot see tenant A rows', async () => {
// TODO: seed a row with tenant_id = 'tenant-a' using your own seed helper
const rows = await queryAsTenant(
testPool,
'tenant-b',
'SELECT * FROM invoices WHERE tenant_id = $1',
['tenant-a']
);
expect(rows).toHaveLength(0);
});For the full walkthrough of why this pattern catches bugs that a happy-path test misses, see the paired guide on testing PostgreSQL RLS with Jest linked below.
Should I use the session variable or role-per-tenant strategy?
Session variable (SET LOCAL app.tenant_id) is the more common pattern for application backends: one shared database role, with the application setting the tenant on every connection or transaction. It is what most NestJS, Express, and Fastify setups already use, and it is the strategy the paired blog post walks through in depth.
Role-per-tenant (matching against current_user) fits setups where PostgreSQL roles map directly to tenants, such as a database-per-customer-role architecture. It needs one login role per tenant to exist ahead of time, which the generated SQL leaves as a comment rather than assuming for you.
Does the generated SQL include FORCE ROW LEVEL SECURITY?
Yes, every generation includes ALTER TABLE ... FORCE ROW LEVEL SECURITY alongside ENABLE ROW LEVEL SECURITY. Without FORCE, the table owner and any superuser connection bypass every policy by default, which is the single most common reason a manually written RLS test passes even though the policy is broken.