RLS policies in YAML, checked in CI with inz vet
Write Postgres row-level security as YAML in instancez.yaml, then run inz vet in CI to catch open policies before they ship, including agent-written ones.
In instancez, row-level security policies are YAML. Each table in instancez.yaml has an rls: list, and each entry becomes a Postgres policy. Because the policies are plain text in one file, a linter can read them. inz vet does that offline and fails your CI build when a policy lets anyone write, or forgets to check who the caller is.
This post shows how the YAML maps to Postgres, what a bad policy looks like, the real inz vet output for it, a script that proves the leak at runtime, and how to wire vet into CI.
How does RLS work in instancez?
RLS is the only authorization layer. There is no HTTP-level role system and no role table to keep in sync. The middleware validates the JWT and runs SET LOCAL ROLE to one of three Postgres roles. From there, Postgres enforces the policies.
| Request credential | Postgres role | RLS |
|---|---|---|
| Publishable key, no user token | anon | enforced |
| Publishable key + user token | authenticated | enforced |
| Secret key | service_role | bypassed |
The role names and JWT claims match Supabase, so @supabase/supabase-js behaves the same way it does against Supabase: sign in, and the same query returns different rows.
What does an RLS policy look like in YAML?
Each policy has operations, using and with_check:
tables:
posts:
rls_enabled: true
fields:
- name: id
type: bigserial
primary_key: true
- name: user_id
foreign_key:
references: auth.users.id
on_delete: cascade
- name: title
type: text
required: true
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"
operationsis exactly one ofselect,insert,update,delete, or all four together.usingdecides which existing rows the operation can see or touch. It becomes the policy’sUSINGclause.with_checkdecides what a written row may look like. It becomesWITH CHECK.type: restrictivemakes a policy narrow access with AND instead of widening it with OR. The default ispermissive.
Inside the expressions you get the helpers Supabase users know: auth.uid(), auth.role(), auth.email(), auth.jwt(), plus auth.is_authenticated().
rls_enabled turns RLS on or off for the table:
rls_enabled | Policies | Result |
|---|---|---|
true | any, including none | ENABLE and FORCE ROW LEVEL SECURITY. With no policies, anon and authenticated get nothing. |
false | none | RLS off: every role reads and writes every row. inz validate warns. |
false | one or more | Validation error, because the policies would never apply. |
Set it on every table. rls_enabled: true with no policies is the safe default for a new table: deny everything, then add policies to open what you need. When you change a policy and save, instancez diffs the file against the database and applies only the change. For the rest of the file, see your whole backend in one YAML file.
What does a bad policy look like?
Here’s a realistic mistake. The blog above gets a comments table, and the policies come back like this:
rls:
- operations: [select]
using: "true"
- operations: [insert]
with_check: "auth.uid() = user_id"
- operations: [update]
using: "true"
with_check: "true"
- operations: [delete]
using: "auth.is_authenticated()"
It looks fine at a glance. Reads are public, which is what we want. Inserts check the owner. But the update policy lets anyone change any comment, and the delete policy only checks that the caller is signed in, so any user can delete anyone’s comment.
This kind of change is easy to miss in review, whether a person or a coding agent wrote it. We cover the agent workflow in a backend for coding agents.
What does inz vet catch?
inz vet reads instancez.yaml and nothing else: no database, no network. Here’s its real output on the full file (the blog’s posts table plus the comments table above):
$ inz vet
inz vet instancez.yaml
● CRITICAL policy-open-write instancez.yaml:65
tables.comments.rls[2].using
This policy lets any caller, signed in or not, update rows in comments.
Fix: Scope it to the caller, for example auth.uid() = user_id.
● HIGH policy-no-identity-write instancez.yaml:68
tables.comments.rls[3].using
This policy never checks the caller's identity, so any user who passes it can change
other users' rows in comments.
Fix: Add an identity check, for example auth.uid() = user_id.
● LOW signup-unverified-email instancez.yaml:9
auth.email.verify_email
Anyone can sign up with an email address they do not own, and no email provider is
configured to send verification mail.
Fix: Configure providers.email, then set auth.email.verify_email: true, or set
auth.allow_signup: false.
● LOW policy-open-read instancez.yaml:61
tables.comments.rls[0].using
This policy lets any caller, signed in or not, read all rows in comments.
Fix: Scope it to the caller, or keep it only if the table is truly public.
● LOW policy-open-read instancez.yaml:29
tables.posts.rls[0].using
This policy lets any caller, signed in or not, read all rows in posts.
Fix: Scope it to the caller, or keep it only if the table is truly public.
1 critical · 1 high · 0 medium · 3 low · 0 info
17 of 21 checks passed
✗ failed: findings at or above high
The exit code is 1. Both problems are caught, each with the YAML path, the line number and a suggested fix. The two policy-open-read findings are low because public reads on a blog are often intended. vet points them out and leaves the call to you.
Change the two policies to auth.uid() = user_id and run it again:
0 critical · 0 high · 0 medium · 3 low · 0 info
19 of 21 checks passed
✓ passed
Does the bad policy actually leak?
Yes. vet only reads the file, so to see what the policies do at runtime, run the backend and send real requests. inz dev --embedded-pg starts the whole backend with a built-in Postgres, so this needs no database install (more on local dev).
This script signs up Alice and Bob, has Alice write a comment, then has Bob and an anonymous client try to edit it and Bob try to delete it:
// check-rls.mjs
import { createClient } from '@supabase/supabase-js'
const url = 'http://localhost:8080'
const key = process.env.INSTANCEZ_PUBLISHABLE_KEY
const client = () => createClient(url, key, { auth: { persistSession: false } })
async function signUp(name) {
const c = client()
const { data, error } = await c.auth.signUp({
email: `${name}-${Date.now()}@example.com`,
password: 'password-123456',
})
if (error) throw error
return { c, id: data.user.id }
}
const alice = await signUp('alice')
const bob = await signUp('bob')
const { data: post } = await alice.c.from('posts')
.insert({ user_id: alice.id, title: 'hello' }).select().single()
const { data: comment } = await alice.c.from('comments')
.insert({ post_id: post.id, user_id: alice.id, body: 'first' }).select().single()
await bob.c.from('comments').update({ body: 'edited by bob' }).eq('id', comment.id)
await client().from('comments').update({ body: 'edited by anon' }).eq('id', comment.id)
const { data: after } = await client().from('comments')
.select('body').eq('id', comment.id).single()
console.log('body after bob and anon tried to edit it:', after.body)
const { data: deleted } = await bob.c.from('comments')
.delete().eq('id', comment.id).select()
console.log('rows bob deleted:', deleted.length)
Run it with the key that inz dev writes to .development.env on first start:
inz dev --embedded-pg
node --env-file=.development.env check-rls.mjs
The results, on the same data:
| Check | Bad policies | Fixed policies |
|---|---|---|
| Comment body after Bob and anon tried to edit it | edited by anon | first |
| Rows Bob deleted from Alice’s comment | 1 | 0 |
Bob inserts a comment with user_id set to Alice | rejected by RLS | rejected by RLS |
The insert row is the same in both columns because the insert policy was correct all along. Its with_check stopped Bob with new row violates row-level security policy for table "comments".
Keep a script like this next to the YAML and run it whenever policies change. vet tells you a policy looks open. The script tells you what Bob can actually do.
Which update mistake does inz validate catch?
There’s a second classic mistake: an update policy with only with_check. On a permissive policy, Postgres has no using to decide which rows are visible to the update. As of v0.0.11, inz validate rejects it before anything reaches the database:
$ inz validate
✗ Error: tables.comments.rls[2].using
using is required: select, delete, or update reads it and has no fallback
Found 1 error(s)
Set using on every permissive update policy, even when it repeats with_check. A restrictive update policy can use with_check alone to add an extra write condition on top of a permissive one.
How do I run inz vet in CI?
inz vet exits 1 when any finding reaches --fail-on (default high), so it works as a CI gate. In GitHub Actions:
name: vet
on: pull_request
jobs:
vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://get.instancez.ai | sh
- run: ~/.local/bin/inz validate
- run: ~/.local/bin/inz vet --fail-on medium
The installer puts inz in ~/.local/bin. --fail-on medium also blocks medium findings, such as a signed-in-only read on a table with sensitive columns. Use --json if you want to post findings as review comments; it prints every finding, even those below --fail-on, with the rule, severity, path, line and fix.
The same report is on the dashboard’s Security page, and the running server serves it at GET /_admin/vet with the secret key.
What else does inz vet check?
It runs 21 rules. The ones closest to RLS:
| Rule | Severity | Flags |
|---|---|---|
rls-disabled | critical | A table with rls_enabled: false |
policy-open-write | critical | An insert, update or delete policy that is always true |
policy-no-identity-write | medium, high | A write policy that never checks the caller’s identity |
policy-open-read | low, high | A select policy that is always true; high with sensitive columns |
policy-authed-read-sensitive | medium | A signed-in-only read on a table with sensitive columns |
bucket-open-write | critical | A storage policy that lets any caller with a JWT write or delete |
rpc-definer-no-auth | low to high | A security: definer function callable without sign-in |
Others cover hardcoded secrets, long JWT expiry, unverified sign-up, insecure redirect URLs, CORS and typo’d config keys. The full list is in the inz vet reference, and policy syntax is in the RLS docs.
FAQ
Does inz vet need a database?
No. It reads instancez.yaml only and does not interpolate ${...} values, so it runs anywhere the binary runs, including CI.
Are these policies the same as Supabase RLS policies?
They are Postgres policies. using and with_check pass straight through to Postgres, and the roles and auth.uid() helper match Supabase. If you’re moving a project over, see migrating from Supabase.
Does the secret key bypass these policies?
Yes. Requests with the secret key run as service_role, which bypasses RLS. Keep it server-side.
Does this work if I self-host?
Yes. vet and the policies are part of the open source binary, the same one that runs on instancez Cloud. See self-hosting instancez and /compare.