Skip to content
instancezinstancez

RLS Policies

Row-level security is the only authorization layer in instancez. All access decisions are Postgres policies declared in instancez.yaml.

instancez has no HTTP-level RBAC. Every access decision is a Postgres row-level security (RLS) policy declared in instancez.yaml under the table’s rls: block. The HTTP middleware validates the JWT and issues SET LOCAL ROLE to the correct Postgres role; from there, Postgres enforces the policies. There is no application-side role table and no separate permission system to synchronize.

Each entry in rls: is a policy object with these fields:

  • operations — one or more of select, insert, update, delete, for example [insert, update]. Each operation gets its own policy.
  • using — a SQL boolean expression that decides which existing rows the operation can see or target. Read by select, update, delete.
  • with_check — a SQL boolean expression that decides what a written row is allowed to look like. Read by insert, update.
  • at least one of using/with_check is required, whichever your operations read. select/delete have no with_check fallback and insert has no using fallback, so those always need the matching field directly. For update alone, the two fields aren’t symmetric. Setting only using gets with_check auto-filled with the same expression, for both permissive and restrictive policies. Setting only with_check auto-fills using only when type: restrictive is set. On a permissive (default) update policy, with_check alone leaves using unset, and that blocks every update through the policy, so set using explicitly there too.

An optional type field accepts permissive (default) or restrictive. Multiple permissive policies on the same table and operation combine with OR — a row passes if any policy allows it. A restrictive policy additionally narrows the result with AND — the row must also satisfy every restrictive policy.

tables:
posts:
rls_enabled: true
fields:
- name: id
type: bigserial
primary_key: true
- name: user_id
type: uuid
required: true
- name: body
type: text
required: true
rls:
# Anyone can read
- operations: [select]
using: "true"
# Only the owner can write
- operations: [insert]
with_check: "auth.uid() = user_id"
- operations: [update]
using: "auth.uid() = user_id"
with_check: "auth.uid() = user_id"
- operations: [delete]
using: "auth.uid() = user_id"

Every table should say whether RLS is on:

tables:
posts:
rls_enabled: true
fields: [...]
rls: [...]
rls_enabled Policies Result
true any, including none ENABLE + FORCE ROW LEVEL SECURITY. With no policies, anon and authenticated are denied everything (reads return zero rows, writes fail). Removing the last policy never turns RLS off.
false none RLS disabled: every role can read and write every row. inz validate warns, like Supabase’s rls_disabled_in_public lint.
false one or more Validation error: the policies would never apply.
not set none Treated as false (RLS off). inz validate warns to set it explicitly.
not set one or more Treated as true. inz validate warns to set it explicitly.

The unset behavior matches instancez before rls_enabled existed, so existing projects migrate with no DDL change. service_role (the secret key) bypasses RLS in every case.

Known gap: if rls_enabled is unset and you remove a table’s last policy, the inferred value flips from true to false and RLS turns off — unlike the explicit true case above, where removing the last policy leaves RLS enabled and deny-all. Set rls_enabled explicitly to avoid this; the inz validate/inz dev warning for an unset field covers it.

Changing the value migrates the table: true → false runs DISABLE ROW LEVEL SECURITY, false → true runs ENABLE + FORCE.

instancez passes using straight through to Postgres’s USING clause and with_check to WITH CHECK. This matches standard Postgres semantics, including the asymmetric update-only auto-fill behavior described above.

inz validate catches expressions that can never parse (unbalanced parentheses or quotes, = =, a leading or trailing AND/OR). Unknown columns are only caught by inz validate --use-dsn --dry-run-ddl.

instancez installs these helper functions in the auth schema at startup. They read session variables set by the request middleware, not application memory.

Function Return type Returns non-null when
auth.uid() uuid Request carries a valid JWT with a sub claim (i.e. a signed-in user). Returns NULL for anon requests and for service_role tokens.
auth.role() text Always returns a value: 'anon', 'authenticated', or 'service_role'.
auth.email() text Request carries a JWT with an email claim.
auth.jwt() jsonb Request carries any JWT. Returns the full decoded payload.
auth.is_authenticated() boolean Role is authenticated or service_role. Returns false for anon.

auth.uid() is the right function for owner-scoped policies. auth.is_authenticated() is useful as a simpler signed-in-only gate. The underlying implementation reads session GUCs (app.user_id, app.role, etc.) set at the start of every request transaction.

The auth schema is not readable from anon/authenticated requests

Section titled “The auth schema is not readable from anon/authenticated requests”

anon and authenticated can call the helpers above, but they have no privileges on the auth.* tables, the same as on Supabase. A policy or security: invoker RPC running as one of those roles that runs SELECT … FROM auth.users fails with permission denied. Keep user data you need in policies in your own table (for example a profiles table with user_id referencing auth.users.id), or read it in an RPC declared with security: definer. Foreign keys to auth.users.id keep working. Requests made with the secret key (service_role) are unaffected and can still read auth.* directly, including through an invoker RPC.

Anyone can read; only the row’s owner can modify it.

rls:
- operations: [select]
using: "true"
- operations: [insert]
with_check: "auth.uid() = user_id"
- operations: [update]
using: "auth.uid() = user_id"
with_check: "auth.uid() = user_id"
- operations: [delete]
using: "auth.uid() = user_id"

Any authenticated user can access the table; anonymous requests cannot.

rls:
- operations: [select, insert, update, delete]
using: "auth.is_authenticated()"
with_check: "auth.is_authenticated()"

owner: <column> replaces using and with_check for the common “user_id = auth.uid()” rule. operations defaults to all four, and the column must be a uuid (or an untyped foreign key to auth.users.id). owner cannot be combined with using or with_check.

rls:
- owner: user_id
- operations: [select]
owner: user_id

On a bucket, owner must be uploaded_by.

Only the row’s owner can see or modify it.

rls:
- operations: [select, insert, update, delete]
using: "auth.uid() = user_id"
with_check: "auth.uid() = user_id"

using and with_check don’t have to match. A common case: a user can see any row they own, but can only save changes to it while it isn’t locked.

rls:
- operations: [update]
using: "owner_id = auth.uid()"
with_check: "owner_id = auth.uid() AND status != 'locked'"

If you only set using on an update policy, Postgres reuses it as with_check too, checked against the new row. This works whether the policy is permissive or restrictive.

The other direction doesn’t hold in general. Setting only with_check on a permissive update policy (permissive is the default type) does not gate writes while leaving reads open: it leaves using unset, so the policy matches zero rows and no update ever goes through it. Set using explicitly on a permissive update policy even when it repeats with_check.

with_check alone is valid on a restrictive update policy: a missing using there is a no-op (restrictive policies narrow visibility granted by a permissive policy elsewhere, they don’t grant any of their own), so with_check still enforces the write gate. Use this to layer an extra write condition on top of a separate permissive policy:

rls:
- operations: [update]
using: "owner_id = auth.uid()"
with_check: "owner_id = auth.uid()"
- operations: [update]
type: restrictive
with_check: "status != 'locked'"

A policy can call an RPC declared under rpc: (using: "public.can_see(id)"). instancez creates RPCs before policies, and removing an RPC first drops the managed policies that call it; a policy created outside instancez.yaml that calls it blocks the drop.

The service_role has BYPASSRLS in Postgres — it skips all policies. Requests made with the secret key are automatically assigned service_role, so they see every row regardless of any using/with_check expression. This applies both to the REST API (when the caller passes the secret key in the apikey header) and to code functions that use the backend client.

In code functions, use ctx.serviceClient to get a client that runs as service_role:

export default async function handler(ctx) {
// Bypasses RLS — use only for trusted server-side logic.
const { data } = await ctx.serviceClient.from('posts').select('*');
return Response.json(data);
}

Use ctx.supabase for operations that should respect RLS and run as the calling user.

The middleware maps each request to one of three Postgres roles. The publishable key selects anon by default; a valid user token upgrades the request to authenticated; the secret key selects service_role outright.

Request credential Postgres role (default name) BYPASSRLS
Publishable key, no user token anon No
Publishable key + valid user token authenticated No
Secret key service_role Yes

The Postgres role names default to the values in the table above, matching Supabase. They are configurable via INSTANCEZ_DB_ANON_ROLE, INSTANCEZ_DB_AUTHENTICATED_ROLE, and INSTANCEZ_DB_SERVICE_ROLE environment variables — but the JWT claim values (anon, authenticated, service_role) are fixed and cannot be changed. They are part of the Supabase wire format.

The request pool logs in as the authenticator role, which is NOINHERIT. Without an explicit SET LOCAL ROLE, it carries no table privileges. Every request transaction starts by issuing SET LOCAL ROLE to the appropriate role, then runs the query, so the role is always correct for the lifetime of that transaction.

Run inz vet to catch open policies and disabled RLS before you deploy. See inz vet.

  • Auth — how users sign up and get JWTs
  • Tables / Schema — table and column definitions
  • Storage — per-bucket RLS policies
  • Querying — filtering and embedding from the client