Security rules
Every table in your app's database has exactly one visibility level, and that single setting decides who can read, insert, update, and delete its rows. There are no policy scripts to write and nothing to configure in code; you pick a level per table and the backend enforces it on every request, no matter where the request comes from. This page explains the six levels, the guarantees the platform adds on top, and how to choose the right level for common situations.
The six visibility levels
Each level answers the same five questions: can anonymous visitors read, can signed-in users read, can anonymous visitors insert, can signed-in users insert, and who can update or delete.
| Level | Anon read | Signed-in read | Anon insert | Signed-in insert | Update / Delete |
|---|---|---|---|---|---|
private | no | own rows | no | yes (stamped) | own rows |
authenticated | no | all rows | no | yes | all rows |
public_read | all | all | no | yes (own) | own rows |
public_create | no | no | yes | yes | owner only |
public_read_create | all | all | yes | yes | owner only |
public | yes | yes | yes | yes | anyone |
A few of these deserve a sentence. private is the personal-data level: each signed-in user sees and edits only their own rows. authenticated is a shared workspace: any signed-in user sees and edits everything. public_create is a drop box: anyone can submit, nobody but you can read. public has no protection at all, so reserve it for genuinely open data like a public guestbook.
Secure by default
A table with no visibility configured is invisible to end users entirely; only you (the owner) and workspace editors can touch it. Forgetting to configure a table can never accidentally expose data, it can only make a feature return empty results until you pick a level.
Two callers bypass the rules: the app owner and users with the admin role. That is what makes an owner-gated admin page work; your own session reads everything regardless of the table's level.
Ownership is stamped, not supplied
On every insert by a signed-in user, the backend stamps the row's user_id from the session, server-side. Any user_id the client sends is overwritten or deleted, so ownership cannot be forged. Reads on private tables are automatically filtered to the caller's own rows.
Never include user_id in insert data and never add a user_id filter to queries. The platform handles both, and client-side user_id logic is at best redundant and at worst a bug that hides rows or fights the stamping.
// Correct: no user_id anywhere. On a private table this
// inserts as the signed-in user and reads only their rows.
const { data: notes } = useQuery("notes");
const { insert } = useMutation("notes");
await insert({ body: "Remember the milk" });
403 is a signal, not an error
When an anonymous visitor queries a private or authenticated table, the backend answers 403. That is the design working: the visitor is not allowed to see the data, so the app should show a signed-out empty state or a login prompt. Treat 403 as an expected branch (check error.status from the Data API), not something to log or alert on.
Choosing the right level
Start from who must read the data, because that is where mistakes hurt.
For customer form submissions (contact requests, waitlist entries, survey answers), use public_create and build an owner-gated /admin page to read them. Visitors can submit, no one can read anyone else's submission, and your owner session bypasses the rules on the admin page. Wrap the page with RequireAuth and useIsOwner from the Authentication API for the UI, and let the visibility level do the real enforcement.
Never put customer personal information in a level with public read (public_read, public_read_create, or public). Names, emails, addresses, and order details belong in private, public_create, or an owner-only table, where anonymous readers get nothing.
The payments table is a special case: it is private and managed by the platform. Confirmed payments are written into it automatically when you use the Stripe integration, and each signed-in user can only read their own payments, which is exactly what makes useQuery("payments") a safe entitlement check.
For per-user app data (tasks, notes, settings), private is almost always right. For content everyone should see but only its author should edit (blog posts, listings), use public_read. For team tools where every signed-in user shares the same data, use authenticated.
Changing a level later
Visibility is not permanent. Change it any time in the Data panel (Cloud, then Data, select the table) or just ask Roc in chat, for example "make the reviews table publicly readable". Roc sets the level when it creates tables for you, and it defaults to the safe choice for the feature it is building. For the broader picture of tables and data management, see Database.