Skip to content

Row rules

A row rule limits a table to the rows that belong to the caller. Each rule says: for this table and this operation, only rows where this column equals this claim from the caller’s access token. The data API and realtime both enforce the same rules, so a row a user cannot read through GET /v1/data/... is not sent to them as a realtime event either.

A request with no signed-in user (a publishable key and no user access token) is denied on every table unless that table has a public rule for the operation. Reads return an empty list, writes return 403, and realtime sends no events.

Signed-in users are limited by the table’s claim rules. A table with no rules at all is open to every signed-in user, for reading and writing: any user of your app can read, change and delete every row. Give every table that holds user data a rule.

The users table that user table sync creates comes with a rule: each signed-in user sees only their own row.

A posts table has an author_id column holding the author’s user id. A rule with:

Field Value
Table posts
Operation ALL
Column author_id
Claim sub

makes every read, insert, update and delete on posts apply author_id = <the user's id>, because sub in a project user’s access token is their user id.

For a claim rule that covers inserts (operation INSERT or ALL), the server sets the rule’s column from the signed-in user on every insert and upsert: with the rule above, a new post’s author_id is the caller’s sub. Leave the column out of the request; a row that names someone else is refused. An update (PATCH) cannot change the owner column. vaif generate knows this and makes the column optional in the table’s Insert type (see Types for your tables).

Rules are managed through the API, or written in a schema file (see Rules from a schema file); the console has no screen for them yet. Call it as a project developer, with your console access token:

Terminal window
curl -X POST https://api.vaif.studio/rls/policies/$PROJECT_ID \
-H "Authorization: Bearer $VAIF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tableName": "posts",
"name": "Authors own their posts",
"operation": "ALL",
"field": "author_id",
"matchClaim": "sub"
}'
Field Meaning
tableName The table the rule applies to
operation SELECT, INSERT, UPDATE, DELETE or ALL. Reads (list, get, search, aggregates, realtime) use SELECT
access Optional. claim (the default), public (see Public tables), server_only (on ALL) or signed_in_read (on SELECT); see Server-only and shared tables
field The column compared. Required for a claim rule; leave it out of every other kind
matchClaim The access-token claim it must equal, such as sub (user id) or email. Required for a claim rule; leave it out of every other kind
enabled Optional; defaults to on. Turn a rule off without deleting it with POST /rls/policies/:policyId/toggle

List a project’s rules with GET /rls/policies/:projectId; change one with PATCH /rls/policies/:policyId; remove one with DELETE /rls/policies/:policyId.

Several rules on the same table and operation are combined with AND.

Some tables are meant to be read by visitors who are not signed in, such as published blog posts or a product catalog. Give such a table a public rule:

Terminal window
curl -X POST https://api.vaif.studio/rls/policies/$PROJECT_ID \
-H "Authorization: Bearer $VAIF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tableName": "posts",
"name": "Anyone can read posts",
"operation": "SELECT",
"access": "public"
}'

A public rule lets requests with no signed-in user perform that operation on every row of the table. Use SELECT for public reads; INSERT or ALL also opens writes to anyone who has your publishable key, so use them only for a table such as a contact form’s. Public rules do not change what signed-in users see: they stay limited by the table’s claim rules.

A table with no rule is open to every signed-in user, for reading and writing. Two more kinds cover tables that are not owned row by row:

  • Server-only ("access": "server_only", operation ALL; -- vaif:server-only in a schema file): only your server, with the secret key, reads or changes the table. No request with a publishable key may, signed in or not, through the data API or realtime. Use it for tables your server maintains, such as team memberships, audit logs and billing records: a user must never be able to add themselves to a team.
  • Signed-in read ("access": "signed_in_read", operation SELECT): every signed-in user reads every row, on purpose. -- vaif:owner-writes <column> in a schema file adds it with claim rules on INSERT, UPDATE and DELETE: every signed-in user reads the table, each changes only their own rows, and the owner column is filled in from their sign-in. Use it for shared feeds and chat. It opens nothing to requests with no signed-in user.

The security advisor counts both as covered and names the tables that use them: a server-only table as one only your server reads or changes, and a signed-in read table as one every signed-in user can read, on purpose.

A SQL file applied with vaif db push, or with Apply to Schema in Verament, can create the common rules for you. Put the directive on the line directly above the CREATE TABLE:

-- vaif:owner owner_id
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL,
body text
);
-- vaif:public-read
-- vaif:owner author_id
CREATE TABLE posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
author_id uuid NOT NULL,
title text NOT NULL
);
  • -- vaif:owner <column> writes a claim rule <column> = sub for ALL: each signed-in user reads, updates and deletes only their own rows, and inserts are stamped with their id. The column must be uuid or text.
  • -- vaif:public-read writes one public SELECT rule.
  • With both, the owner rule covers INSERT, UPDATE and DELETE only, so everyone can read the table and only the author can change a row.
  • -- vaif:server-only writes one server-only rule on ALL: only your server, with the secret key, reads or changes the table. It can’t be combined with another directive.
  • -- vaif:owner-writes <column> writes a signed-in read rule plus claim rules <column> = sub on INSERT, UPDATE and DELETE, with inserts stamped like -- vaif:owner: every signed-in user reads the table, and each changes only their own rows. It can’t be combined with -- vaif:owner.
-- vaif:server-only
CREATE TABLE team_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
team_id uuid NOT NULL,
user_id uuid NOT NULL,
role text NOT NULL
);
-- vaif:owner-writes author_id
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
author_id uuid NOT NULL,
body text NOT NULL
);

All are additive: a table that already has rules of that kind keeps them, and nothing is ever removed.

Rules match against the access token the request carries, so create the client with the signed-in user’s token:

import { createVaifClient } from "@vaifstudio/client";
let accessToken = ""; // set after sign-in
const vaif = createVaifClient({
baseUrl: "https://api.vaif.studio",
projectId: process.env.VAIF_PROJECT_ID,
apiKey: process.env.VAIF_PUBLISHABLE_KEY,
accessToken: () => accessToken,
});
// With the rule above, this returns only the signed-in user's posts.
const { data: mine } = await vaif.from("posts").list();
console.log(mine);

Only tokens VAIF issued are accepted. A token with a bad signature, or one that has expired, is treated as no token.

A request with no user token has no claims to match, so it is denied unless the table has a public rule (see Closed by default). A signed-in user whose token lacks the claim a rule needs is denied too, instead of seeing the whole table.

Requests made with a secret (vk_srv_…) key are not subject to row rules, and have no signed-in user, so nothing fills an owner column for them: send it yourself. Use a secret key only on servers you control, never in a browser or app bundle; the API refuses secret keys from browser origins. See Key types.