Security Rules (RLS)
Overview
Section titled “Overview”Security rules let you define Row Level Security (RLS) policies for your PostgreSQL tables directly in your collection definitions. When the Drizzle schema is generated, Rebase creates the corresponding CREATE POLICY statements.
import { defineCollection } from "@rebasepro/cms-types";const postsCollection = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { /* ... */ }, securityRules: [ { operation: "select", access: "public" }, { operations: ["insert", "update", "delete"], ownerField: "authorId" } ]});How It Works
Section titled “How It Works”- You define
securityRuleson a collection rebase schema generatecreates Drizzle schema with RLS enabledrebase db pushorrebase db migrateapplies the policies to PostgreSQL- Every query is filtered by the current user’s context automatically
The authenticated user’s identity is available in SQL via:
| Function | Returns |
|---|---|
rebase.uid() |
The current user’s ID |
rebase.roles() |
Comma-separated app role IDs |
rebase.jwt() |
Full JWT claims as JSONB |
These are set automatically per-transaction by the Rebase backend.
Policies a collection gets without writing them
Section titled “Policies a collection gets without writing them”The policies on a table are more than the collection’s own securityRules:
defaultSecurityRules, exported fromconfig/collections/index.ts. A collection whosesecurityRulesis absent or empty takes all of them in its place, as if it had written them. A collection that declares even one rule of its own takes none: the two lists are never merged. They are applied wherever the directory is read:rebase dev,rebase db push, the admin panel and a backend givencollectionsDir. Since 0.25 The scaffold’s index also applies them to thecollectionsarray it exports, so a backend handed that array gets them too. An array put together any other way carries only the rules its collections declare.- The admin baseline. Every collection also gets
<table>_default_admin_readand<table>_default_admin_write_insert,_updateand_delete: permissive policies that let the server context and theadminrole read and write every row. They sit beside whichever rules the collection ends up with, its own or the inherited defaults, and permissive policies are OR’d, so they widen both. The users collection also gets a self-read and a restrictive write gate that keepsrolesadmin-only. - Tenancy. A tenant-scoped collection gets a restrictive rule that confines every operation to the caller’s tenant; the server context passes it.
disableDefaultPolicies: true on a collection removes the permissive baseline,
and keeps the restrictive gates. It is per collection: defaultSecurityRules
carries no such flag, so a collection that inherits the defaults keeps the
baseline unless it sets the flag itself. The generated DDL marks every injected
policy, and rebase db push puts back one dropped by hand.
Convenience Shortcuts
Section titled “Convenience Shortcuts”Owner-based Access
Section titled “Owner-based Access”The simplest pattern — users can only access rows they own:
securityRules: [ { operation: "all", ownerField: "user_id" }]This generates: USING ((user_id)::text = (SELECT rebase.uid())) — see
Evaluated once per statement for the SELECT.
Public Access
Section titled “Public Access”Allow anyone (including unauthenticated users) to read:
securityRules: [ { operation: "select", access: "public" }]This generates: USING (true)
Authenticated Access
Section titled “Authenticated Access”Allow any signed-in user. This one is a condition rather than an access
shortcut — access has exactly one value, "public" — because “signed in” is a
test against the caller, and the builder is where tests against the caller live:
import { policy } from "@rebasepro/types";
securityRules: [ { operation: "select", condition: policy.authenticated() }]policy.authenticated() is true for anonymous sign-in too, which mints a real
user row and a real session. Use policy.registered() where a guest should not
qualify — writing a review, joining an organization, spending money.
Role-based Access
Section titled “Role-based Access”Restrict operations to specific roles:
securityRules: [ { operation: "all", roles: ["admin"] }, { operation: "select", roles: ["editor", "viewer"] }]Membership / Relational Access
Section titled “Membership / Relational Access”To scope access by membership in a related collection — e.g. “only rows whose
team the caller belongs to” — use the structured condition with
policy.existsIn. It compiles to a single correlated EXISTS subquery (no
per-row lookups), and is the safe, first-class alternative to hand-writing the
raw SQL shown below.
import { policy } from "@rebasepro/types";
// documents visible only to members of the document's team:securityRules: [ { operation: "select", condition: policy.existsIn({ collection: "team_members", // the join / membership collection where: policy.and( // correlate to the row being checked: policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")), // …and to the caller: policy.compare(policy.field("user_id"), "eq", policy.authUid()), ), }), },]Inside where, policy.field(...) refers to a column of the joined collection
(team_members), while policy.outerField(...) refers to a column of the row
being checked (documents). Combine with policy.authUid() to scope to the
current user. Because it is enforced by the database, the admin UI treats it as
server-authoritative.
The policy builder, in full
Section titled “The policy builder, in full”Imported from @rebasepro/types. Expressions compose; operands are the leaves.
| Expression | Compiles to |
|---|---|
policy.true() / policy.false() |
true / false |
policy.and(…) / policy.or(…) |
conjunction / disjunction |
policy.not(e) |
negation |
policy.compare(left, op, right) |
a comparison between two operands |
policy.rolesOverlap(roles) |
the caller has any of these app roles |
policy.rolesContain(roles) |
the caller has all of these app roles |
policy.authenticated() |
signed in — rebase.uid() is set and is not an anonymous sentinel. IS NOT NULL alone would be a tautology, since an anonymous request sets a sentinel rather than leaving it unset |
policy.registered() |
signed in with an account — authenticated() and not a guest. See below |
policy.serverContext() |
rebase.uid() IS NULL — see the caution below |
policy.existsIn({ collection, where }) |
a correlated EXISTS subquery |
policy.raw(sql) |
an escape hatch, inserted verbatim |
| Operand | Means |
|---|---|
policy.field(name) |
a column of the collection being checked — or, inside existsIn, of the joined one |
policy.outerField(name) |
inside existsIn, a column of the outer row |
policy.literal(value) |
a string, number, boolean or null |
policy.authUid() |
rebase.uid() |
policy.authRoles() |
rebase.roles() |
Evaluated once per statement
Section titled “Evaluated once per statement”Every part of a rule that does not read the row — the caller’s id, a roles
check, a claim, serverContext() — compiles inside a scalar subquery. An owner
rule becomes (user_id)::text = (SELECT rebase.uid()), and the default admin
read becomes
(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])).
Postgres computes such a subquery once per statement (an initPlan) rather than once per row, and plans as though the check passes half the table rather than 1.5% of it. That estimate is what keeps a deep page of a large table on its primary key: without it, page 401 of a million rows was a sequential scan and a 640 MB sort. Who may read or write what is unchanged.
Raw using / withCheck SQL gets the same treatment when Rebase can read its
structure (owner_id = rebase.uid() does). Anything else is inserted as
written, so wrap the calls yourself: (SELECT rebase.uid()).
authenticated() and registered()
Section titled “authenticated() and registered()”Two different things are called anonymous, and it is worth being precise about which one a rule means.
An unauthenticated request carries no session at all. It is given a sentinel
id so that rebase.uid() is never NULL on the user path, and
policy.authenticated() excludes it — that is what makes it mean “signed in”
rather than “anyone”.
A guest is the other thing: a session with nobody behind it.
POST /auth/anonymous mints a real user row with a real uid, so a guest passes
every test that looks at the id. That is the point of the feature — a cart
before checkout, a draft before signup — and it means authenticated() is true
for anybody who pressed Continue as guest, which asks for no email, no
password and no agreement to anything.
policy.registered() is authenticated() plus “not a guest”. Reach for it
wherever a rule is about a person who could be held responsible for something:
writing a review, joining an organization, spending money. Reach for
authenticated() where a guest is genuinely welcome.
// Anyone with a session, guests included — a draft cart.{ operation: "insert", check: policy.authenticated() }
// Someone with an account.{ operation: "insert", check: policy.registered() }Under the hood the guest flag travels with the session — it is in the access
token and reaches the database as rebase.is_anonymous() — so a policy can ask
the question without a lookup. A database served by a server too old to set it
reads every session as an account, which is the behaviour that deployment
already had.
Raw SQL Expressions
Section titled “Raw SQL Expressions”For complex logic, use using and withCheck:
securityRules: [ { operation: "select", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" }]using— Filters which existing rows are visible (applies to SELECT, UPDATE, DELETE)withCheck— Validates new row values (applies to INSERT, UPDATE)
Column references use {column_name} syntax which gets resolved to the full table-qualified column.
Combining Shortcuts and SQL
Section titled “Combining Shortcuts and SQL”Mix convenience shortcuts with raw SQL:
securityRules: [ // Admins can do anything { operation: "all", roles: ["admin"], using: "true" }, // Regular users can only see their own rows { operation: "select", ownerField: "user_id" }, // Users can insert, but only for themselves { operation: "insert", withCheck: "{user_id} = rebase.uid()" }, // Locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false" }]Permissive vs Restrictive
Section titled “Permissive vs Restrictive”PostgreSQL has two policy modes:
- Permissive (default) — Multiple permissive policies are OR’d together. If any one passes, access is granted.
- Restrictive — Restrictive policies are AND’d together. All must pass.
securityRules: [ // Permissive: owners can access their rows { operation: "all", ownerField: "user_id" }, // Restrictive: but locked rows cannot be updated { operation: "update", mode: "restrictive", using: "{is_locked} = false", withCheck: "{is_locked} = false" }]Operations
Section titled “Operations”| Operation | SQL Equivalent | Description |
|---|---|---|
"select" |
SELECT |
Read rows |
"insert" |
INSERT |
Create new rows |
"update" |
UPDATE |
Modify existing rows |
"delete" |
DELETE |
Remove rows |
"all" |
All of the above | Shorthand for all operations |
You can also use operations (plural) to apply one rule to multiple operations:
{ operations: ["insert", "update", "delete"], ownerField: "authorId" }Full SecurityRule Interface
Section titled “Full SecurityRule Interface”SecurityRule is a union, not one open object: a rule picks exactly one way
of expressing its predicate, and the others are typed never so mixing them is a
compile error rather than a policy that silently ignores half of what you wrote.
// Shared by every variantinterface SecurityRuleBase { name?: string; // Policy name. Omit it and one is derived operation?: SecurityOperation; // "select" | "insert" | "update" | "delete" | "all" operations?: SecurityOperation[]; // …or several at once mode?: "permissive" | "restrictive"; // Default: "permissive" roles?: string[]; // App roles, via rebase.roles() pgRoles?: string[]; // Native Postgres roles — the CREATE POLICY `TO` clause. // NOT the same as `roles`. Default: ["public"]}
// …plus exactly one of:{ ownerField: string } // <column> = rebase.uid(){ access: "public" } // the one shortcut — "no row filter"{ condition: PolicyExpression; // the structured builder — `policy.*` check?: PolicyExpression } // defaults to `condition`, as Postgres does{ using?: string; withCheck?: string } // raw SQLroles and pgRoles are the two that get confused. roles is an application
role, enforced inside the USING / WITH CHECK clause through
rebase.roles(). pgRoles is a database role, and controls which connections
the policy is attached to at all. Almost every project wants roles.
Examples
Section titled “Examples”Blog Platform
Section titled “Blog Platform”securityRules: [ // Anyone can read published posts { operation: "select", using: "{status} = 'published'" }, // Authors can see their own drafts { operation: "select", ownerField: "authorId" }, // Authors can create and edit their own posts { operations: ["insert", "update"], ownerField: "authorId" }, // Only admins can delete { operation: "delete", roles: ["admin"] }]Multi-Tenant SaaS
Section titled “Multi-Tenant SaaS”securityRules: [ { operation: "all", using: "EXISTS (SELECT 1 FROM org_members WHERE org_members.org_id = {org_id} AND org_members.user_id = rebase.uid())" }]Anonymous Access (Public Inserts)
Section titled “Anonymous Access (Public Inserts)”A common need is allowing unauthenticated users to submit data — contact forms, newsletter signups, public applications. Rebase provides a clean pattern for this.
Recommended: a raw withCheck rule
Section titled “Recommended: a raw withCheck rule”import { defineCollection } from "@rebasepro/cms-types";
const contactMessagesCollection = defineCollection({ slug: "contact_messages", name: "Contact Messages", table: "contact_messages", securityRules: [ // Anyone can submit a contact message { operation: "insert", // A raw rule carries `using` (which rows are visible) and `withCheck` // (what a write must satisfy); an insert only exercises the latter. using: "true", withCheck: "true" }, // Only admins can read, update, or delete messages { operations: ["select", "update", "delete"], roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } }});The access: "public" shortcut generates a policy that allows the operation without requiring authentication.
For Lead Capture / Signups
Section titled “For Lead Capture / Signups”import { defineCollection } from "@rebasepro/cms-types";
const leadSignupsCollection = defineCollection({ slug: "lead_magnet_signups", name: "Lead Magnet Signups", table: "lead_magnet_signups", securityRules: [ // Allow anonymous inserts { operation: "insert", using: "true", withCheck: "true" }, // Admins can view all signups { operation: "select", roles: ["admin"] } ], properties: { email: { name: "Email", type: "string" } }});How Anonymous Requests Work
Section titled “How Anonymous Requests Work”When a request arrives without a JWT token, the Rebase backend sets the PostgreSQL session variables to:
| Variable | Value |
|---|---|
app.user_id |
'anonymous' |
app.user_roles |
'' (empty) |
This means:
rebase.uid()returns'anonymous'rebase.roles()returns an empty stringaccess: "public"policies pass because they generateUSING (true)/WITH CHECK (true)policy.authenticated()conditions fail because they check for a real user IDownerFieldpolicies fail because no row will haveuser_id = 'anonymous'(unless explicitly set)
Advanced: Raw SQL for Anonymous
Section titled “Advanced: Raw SQL for Anonymous”If you need more granular control, use raw SQL:
securityRules: [ { operation: "insert", withCheck: "rebase.uid() = 'anonymous' OR rebase.uid() IS NOT NULL" }]Rows here, fields next door
Section titled “Rows here, fields next door”Security rules answer one question: which rows does this caller reach. They are enforced by Postgres itself, on every statement, whatever the route — which is why they are the authorization model and everything above them is convenience.
They have nothing to say about the columns of a row a caller does reach. A
policy that lets an employee read their team’s rows lets them read every field of
those rows, salary included. That is what per-property
access is for:
salary: { type: "number", // Everyone the rules above let read the row; only HR gets this column. access: { read: ["hr"], write: [] }}The two stack and never contradict: a field rule cannot widen row access, and a
row you cannot read has no fields to talk about. Roles are the same roles —
rebase.roles() inside a policy, user.roles on the request — so
rolesOverlap(['hr']) in a rule and access: { read: ["hr"] } on a property
mean the same hr. Field rules are applied by the server rather than by
Postgres, so they cover the API surface; a query run through rebase.sql() sees
every column, exactly as it bypasses RLS.
Next Steps
Section titled “Next Steps”- Field access — Per-field read/write roles
- Relations — Foreign keys and joins
- Entity Callbacks — Lifecycle hooks
- Custom Functions — Custom API endpoints