One line per authorization pattern. Maximum performance by default.
A TypeScript DSL for defining PostgreSQL Row Level Security (RLS) policies — with a template system that covers the most common patterns in a single call, a typed API for compile-time validation, and performance optimizations applied automatically.
Warning: This is an experimental project and not an official Supabase library. Use with caution in production.
Try the live demo at https://rowguard-demo.vercel.app/
The demo includes live database testing using the real Supabase migration workflow:
supabase db reset to apply migrationsRun the full demo locally with database:
pnpm install
pnpm demo:dev:full # Starts Supabase + demo
Or run in SQL-only mode (no database):
pnpm demo:dev
The demo source code is in the demo/ directory. See demo/README.md for detailed setup instructions.
owned, shared, membership, tenant, role, immutable, admin, open(SELECT auth.uid()) initPlan caching and TO authenticated on every policy, automaticallyDROP POLICY IF EXISTS before every CREATE POLICY; safe to re-runnpm install rowguard
# pnpm
pnpm add rowguard
# yarn
yarn add rowguard
# bun
bun add rowguard
Preview builds are available via pkg-pr-new for each PR/commit:
# Install a preview build from a PR (check pkg.pr.new badge for latest URL)
npm install https://pkg.pr.new/supabase-community/rowguard@{pr-number}
Generate types from your Supabase schema, then get autocomplete and compile-time validation for every table and column name.
# For remote project
npx supabase gen types typescript --project-id "$PROJECT_REF" > database.types.ts
# For local development
npx supabase gen types typescript --local > database.types.ts
import { createRowguard, policiesToSQL } from 'rowguard';
import type { Database } from './database.types';
const rg = createRowguard<Database>();
const sql = policiesToSQL([
...rg.policies.owned({ tables: ['posts', 'comments'] }),
...rg.policies.shared({ tables: ['projects'], publicColumn: 'is_public' }),
...rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'project_id' }),
...rg.policies.tenant({ tables: ['invoices', 'orders'] }),
...rg.policies.role({ tables: ['admin_logs'], is: 'admin' }),
...rg.policies.open({ tables: ['announcements'] }),
]);
// → ALTER TABLE "posts" ENABLE ROW LEVEL SECURITY;
// → DROP POLICY IF EXISTS ... (idempotent)
// → CREATE POLICY ...
import { policies, policiesToSQL } from 'rowguard';
const sql = policiesToSQL([
...policies.owned({ tables: ['posts', 'comments'] }),
...policies.tenant({ tables: ['invoices'] }),
]);
| Template | Purpose | Default TO |
|---|---|---|
owned |
User owns every row (full CRUD) | authenticated |
shared |
Owner writes; public can read via flag column | authenticated |
membership |
Access via join table (e.g. project members) | authenticated |
tenant |
Hard tenant isolation (RESTRICTIVE) + owner CRUD | authenticated |
role |
JWT claim or roles table check | authenticated |
immutable |
Append-only rows (INSERT only, no UPDATE/DELETE) | authenticated |
admin |
Admin bypass (no TO restriction by default) | (none — all roles) |
open |
Public read | public |
ownedFull CRUD restricted to the row owner. Generates four policies (SELECT, INSERT, UPDATE, DELETE).
policies.owned({ tables: ['posts', 'comments'] })
policies.owned({ tables: ['posts'], userColumn: 'author_id', operations: ['SELECT', 'UPDATE'] })
sharedOwner has full write access; any user can read rows where a boolean flag column is true.
policies.shared({ tables: ['documents'], publicColumn: 'is_public' })
membershipAccess granted through a join table. Useful for team/project membership patterns.
policies.membership({
tables: ['projects'],
via: 'project_members',
key: 'project_id',
})
tenantCreates a RESTRICTIVE isolation policy (tenant boundary cannot be bypassed) plus per-operation owner policies.
policies.tenant({ tables: ['invoices', 'orders'] })
policies.tenant({ tables: ['invoices'], column: 'org_id', ownerPolicies: false })
roleChecks a JWT claim or a roles table. Defaults to checking auth.jwt() ->> 'user_role'.
policies.role({ tables: ['admin_logs'], is: 'admin', operations: ['SELECT'] })
policies.role({ tables: ['reports'], is: ['editor', 'admin'], via: { table: 'user_roles' } })
immutableAppend-only rows. Allows INSERT; blocks UPDATE and DELETE. Optionally adds a SELECT policy for the owner.
policies.immutable({ tables: ['audit_log'], allowRead: true })
adminAdmin bypass — no TO role restriction applied by default, so all database roles can match.
policies.admin({ tables: ['admin_settings'], is: 'admin' })
openPublic SELECT access (no authentication required).
policies.open({ tables: ['announcements', 'pricing'] })
createRowguard<Database>() narrows all template configs to your actual schema:
const rg = createRowguard<Database>();
// table names autocomplete; typos are compile errors
rg.policies.owned({ tables: ['posts'] });
// column names autocomplete; they're validated against the table
rg.policies.shared({ tables: ['docs'], publicColumn: 'is_public' });
// membership.key autocompletes to columns of the via table specifically
rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'project_id' });
// @ts-expect-error — 'nonexistent' is not a column of 'project_members'
rg.policies.membership({ tables: ['projects'], via: 'project_members', key: 'nonexistent' });
// the fluent builder also narrows to your schema
rg.policy('user_documents')
.on('documents') // ← autocomplete for all table names
.read()
.when(rg.column('documents', 'user_id').eq(rg.auth.uid()));
// ↑ autocomplete columns of 'documents'
Two optimizations are applied automatically to every generated policy:
(SELECT auth.uid()) instead of auth.uid() — PostgreSQL evaluates auth.uid() once per query (initPlan) instead of once per row. In benchmarks this produces a 94.97% speedup on large tables.
TO authenticated on every policy — unauthenticated (anon) queries skip policy evaluation entirely rather than evaluating a condition that returns false for every row. Benchmark result: 99.78% speedup for anon traffic.
Both are applied by default. No configuration needed.
policiesToSQL / applyPoliciesimport { policiesToSQL, applyPolicies, enableRLS } from 'rowguard';
// Generate SQL string (for migration files)
const sql = policiesToSQL(builders); // idempotent by default
// Apply directly to a database client
await applyPolicies(builders, client); // runs in a transaction
// Just ENABLE RLS on tables
enableRLS(['posts', 'comments', 'projects']);
For custom policies that go beyond the templates, the fluent builder is available directly:
import { policy, column, auth, from, session } from 'rowguard';
policy('user_documents')
.on('documents')
.read()
.when(column('user_id').eq(auth.uid()));
policy('project_access')
.on('projects')
.read()
.when(
column('is_public')
.eq(true)
.or(column('user_id').eq(auth.uid()))
.or(column('organization_id').eq(session.get('app.org_id', 'uuid')))
);
policy('member_access')
.on('projects')
.read()
.when(
column('id').in(
from('project_members')
.select('project_id')
.where(column('user_id').eq(auth.uid()))
)
);
policy(name)
.on(table) // Target table
.read() // Allow reading (SELECT)
.write() // Allow creating (INSERT)
.update() // Allow updating (UPDATE)
.delete() // Allow deleting (DELETE)
.all() // Allow all operations (ALL)
.for(operation) // SELECT | INSERT | UPDATE | DELETE | ALL
.to(role?) // Optional role restriction
.when(condition) // USING clause (read filter)
.allow(condition) // Type-safe USING/WITH CHECK based on operation
.withCheck(condition) // WITH CHECK clause (write validation)
.requireAll() // All policies must pass (RESTRICTIVE)
.allowAny() // Any policy can grant access (PERMISSIVE, default)
.restrictive() // Mark as RESTRICTIVE
.permissive() // Mark as PERMISSIVE (default)
.toSQL() // Generate PostgreSQL statement
column('status').eq('active');
column('age').gt(18);
column('price').lte(100);
column('email').like('%@company.com');
column('name').ilike('john%');
column('status').in(['active', 'pending']);
column('tags').contains(['important']);
column('deleted_at').isNull();
column('verified_at').isNotNull();
column('user_id').isOwner(); // eq(auth.uid())
column('is_public').isPublic(); // eq(true)
column('user_id')
.eq(auth.uid())
.or(column('is_public').eq(true))
.and(column('status').eq('active'));
import { column, from, auth } from 'rowguard';
column('id').in(
from('project_members')
.select('project_id')
.where(column('user_id').eq(auth.uid()))
);
column('id').in(
from('projects', 'p')
.select('p.id')
.join('members', column('m.project_id').eq('p.id'), 'inner', 'm')
.where(column('m.user_id').eq(auth.uid()))
);
auth.uid(); // Current authenticated user
session.get(key, type); // Type-safe session variable
currentUser(); // Current database user
We welcome contributions! Please see our Contributing Guide for details on:
# Install dependencies
pnpm install
# Build the library
pnpm run build
# Run tests
pnpm test
# Run integration tests (requires Supabase CLI)
pnpm run test:integration:full
# Run the interactive demo
pnpm run demo:dev
For more detailed information, see CONTRIBUTING.md.
This project uses automated releases via release-please.
MIT - see LICENSE file for details