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.
Policy syntax
Section titled “Policy syntax”Each entry in rls: is a policy object with these fields:
operations— one or more ofselect,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 byselect,update,delete.with_check— a SQL boolean expression that decides what a written row is allowed to look like. Read byinsert,update.- at least one of
using/with_checkis required, whichever your operations read.select/deletehave nowith_checkfallback andinserthas nousingfallback, so those always need the matching field directly. Forupdatealone, the two fields aren’t symmetric. Setting onlyusinggetswith_checkauto-filled with the same expression, for both permissive and restrictive policies. Setting onlywith_checkauto-fillsusingonly whentype: restrictiveis set. On a permissive (default)updatepolicy,with_checkalone leavesusingunset, and that blocks every update through the policy, so setusingexplicitly 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"Turning RLS on and off: rls_enabled
Section titled “Turning RLS on and off: rls_enabled”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.
auth.uid() and auth.is_authenticated()
Section titled “auth.uid() and auth.is_authenticated()”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.
Common patterns
Section titled “Common patterns”Public read, owner write
Section titled “Public read, owner write”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"Signed-in only
Section titled “Signed-in only”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 shorthand
Section titled “Owner shorthand”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_idOn a bucket, owner must be uploaded_by.
Private (owner only)
Section titled “Private (owner only)”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"Divergent read/write rules on update
Section titled “Divergent read/write rules on update”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'"Calling an RPC from a policy
Section titled “Calling an RPC from a policy”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.
Admin bypass via service role
Section titled “Admin bypass via service role”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.
How roles are assigned
Section titled “How roles are assigned”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.
What’s next
Section titled “What’s next”- 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