Blog

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 credentialPostgres roleRLS
Publishable key, no user tokenanonenforced
Publishable key + user tokenauthenticatedenforced
Secret keyservice_rolebypassed

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"
  • operations is exactly one of select, insert, update, delete, or all four together.
  • using decides which existing rows the operation can see or touch. It becomes the policy’s USING clause.
  • with_check decides what a written row may look like. It becomes WITH CHECK.
  • type: restrictive makes a policy narrow access with AND instead of widening it with OR. The default is permissive.

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_enabledPoliciesResult
trueany, including noneENABLE and FORCE ROW LEVEL SECURITY. With no policies, anon and authenticated get nothing.
falsenoneRLS off: every role reads and writes every row. inz validate warns.
falseone or moreValidation 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:

CheckBad policiesFixed policies
Comment body after Bob and anon tried to edit itedited by anonfirst
Rows Bob deleted from Alice’s comment10
Bob inserts a comment with user_id set to Alicerejected by RLSrejected 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:

RuleSeverityFlags
rls-disabledcriticalA table with rls_enabled: false
policy-open-writecriticalAn insert, update or delete policy that is always true
policy-no-identity-writemedium, highA write policy that never checks the caller’s identity
policy-open-readlow, highA select policy that is always true; high with sensitive columns
policy-authed-read-sensitivemediumA signed-in-only read on a table with sensitive columns
bucket-open-writecriticalA storage policy that lets any caller with a JWT write or delete
rpc-definer-no-authlow to highA 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.