# Do I need row-level security? Only if the browser can reach your database

Row-level security explained plainly: what it is, why backends that let the browser query the database depend on it, what goes wrong when an agent writes the policies, and why an app whose server holds the only database key does not need it.

Canonical URL: https://getfloo.com/blog/do-i-need-row-level-security

## TL;DR

**You need row-level security when the browser, or any client you do not control, can query your database directly. You do not need it when only your server code holds the database credentials and the browser talks to your server. On floo, the browser gets no database key, sign-in runs at floo's gateway, and your server filters rows by the signed-in person, so there are no RLS policies for your agent to get wrong.**

- **RLS is a Postgres feature, not a product.** It attaches a rule to a table that limits which rows each caller sees.
- **Browser-to-database backends depend on it.** Supabase and Neon's Data APIs accept requests with a public key; a table without a correct policy is open.
- **Agents create tables in ways that skip it.** Supabase's docs say tables made in its SQL editor need RLS turned on by hand, and most agent migrations arrive as SQL.
- **A server in between makes RLS optional.** The check becomes a filter in code, which still has to be right, but is visible, testable, and in one place.

Every claim about another product below comes from that product's own docs, read on November 3, 2026. Sources are at the end.

## What is row-level security?

A table normally has one answer to "can this role read it?": yes or no, for every row. Row-level security adds a per-row answer. You enable it on a table and attach policies, and Postgres applies them to every query against that table.

```sql
alter table tasks enable row level security;

create policy own_tasks on tasks
  for select
  using (owner_email = current_setting('app.user_email'));
```

With that policy, `select * from tasks` returns only the caller's tasks, no matter what the query asked for. The database enforces it, not the app.

That is a strong guarantee, and it is why RLS exists. It is also only as good as the policies: one per table, per operation, kept in step with every schema change.

## When do you need it?

When something you do not control sends queries to the database. In practice that means the browser.

Several backends generate an API straight from your tables and let the browser call it with a key that is public by design:

- **Supabase's Data API** is PostgREST, called from the browser with a publishable key. Supabase's docs say to enable RLS on every table in an exposed schema.
- **Neon's Data API** is PostgREST-compatible and callable from browser frontends. Its docs say it has no permission layer of its own; grants and RLS are the whole defense.
- **InsForge** gives the browser an anon key gated by RLS, and its docs say new tables have RLS on by default.
- **App builders** inherit the same shape. Lovable's docs say its apps rely on RLS, and Bolt's say visitors can send requests to its database straight from the browser.

Other backends use the same idea under a different name. Firebase has Security Rules, which its docs describe as the safeguard against malicious users because clients connect directly. Appwrite has table and row permissions. PocketBase has a rule per collection and operation. None of these are Postgres RLS, but each is a rule per table or path standing between a public key and your data.

## What goes wrong when an agent writes the policies?

The docs of these products list the failure modes themselves:

- **A table without RLS is open.** Supabase's docs say a table in an exposed schema without RLS can be read and written by any role with a grant on it.
- **New tables can be exposed by default.** On existing Supabase projects, new tables in `public` get grants for the `anon` and `authenticated` roles unless you turn that off. Enabling Neon's Data API grants `authenticated` access to `public`, and with RLS disabled any signed-in user sees every row.
- **How a table is created changes the default.** Supabase tables made in the dashboard get RLS on; tables made in the SQL editor or other tools do not. An agent's migration is SQL.
- **A policy can exist and still allow everything.** Supabase's Security Advisor flags policies that are always true, and policies that rely on metadata users can edit themselves.
- **Server keys skip it all.** Supabase secret keys bypass RLS. A secret key that ends up in the wrong place undoes every policy.

None of these are bugs in the products. They are the contract: the database is reachable, and the policies decide. Postgres itself fails closed: a table with RLS enabled and no policy returns no rows to client roles. The trouble is a table with RLS switched off, or a policy that is too broad, and both fail open and silently. The app works, the demo works, and the table is readable by anyone who opens the browser's network tab.

## When don't you need it?

When nothing outside your server can reach the database. If the browser holds no database credential and every query comes from code you deploy, that code is where authorization happens. It knows who is calling, it decides what they may see, and it filters the query.

RLS is then optional. Some teams add it as a second layer, especially when many customers share the same tables. But leaving it off exposes nothing, because there is no public path to the data for a policy to guard.

Several backends support this shape. Supabase's docs describe turning the Data API off entirely, after which no REST endpoint responds. Convex's docs describe reads and writes only through query and mutation functions, each of which checks the caller; Convex's own blog says most Convex apps do not need row-level security. And any app with its own server works this way.

## How does floo handle it?

floo hosts the server and the database together, and gives the browser no way to the database:

```text
browser -> floo gateway (sign-in) -> your server code -> Postgres
```

1. `access_mode = "accounts"` in `floo.app.toml` puts invite-only sign-in at floo's gateway. No person reaches your code without signing in.
2. The gateway adds `X-Floo-User-Email`, `X-Floo-User-Id`, `X-Floo-User-Name`, and `X-Floo-User-Role` to every request.
3. Only your server holds `DATABASE_URL`. floo injects it at deploy time; it never appears in git or in the browser bundle.
4. Programs, such as your agent, call routes you declare with `access = "api_key"`, using a key scoped to those routes. floo strips the key and passes the verified consumer's identity instead.

The permission check is a line in a route:

```ts
const email = request.headers.get("x-floo-user-email");
const role = request.headers.get("x-floo-user-role");

// Only admins may delete, checked on the server, not by hiding a button.
if (request.method === "DELETE" && role !== "admin") {
  return new Response("Forbidden", { status: 403 });
}

// Members see only their own tasks.
const { rows } = await pool.query(
  "select * from tasks where owner_email = $1",
  [email],
);
```

floo's docs describe no RLS setup for managed Postgres, because none is needed for this shape.

## Doesn't that just move the bug?

Partly, and it is worth being honest about. A route that forgets its `WHERE` clause leaks just as surely as a table with RLS left off. floo's API key docs say it directly: scopes gate routes, and your application still owns record-level permissions.

What changes is where the mistake can hide:

- **One place, not every table.** The check sits in the route that returns the data. A new table is not reachable until a route reads it.
- **Visible in review.** "Show me every route that queries the database and the filter it applies" is a question an agent can answer in one pass, and you can read the answer.
- **Testable as a user.** `floo dev --fixture-user` adds the same identity headers locally, so a test can sign in as two people and prove neither sees the other's rows.
- **Parameters, not string building.** Use `$1` placeholders so a value from the request can never become part of the SQL.

## Which setup do you have?

| Your setup | Need RLS? |
| --- | --- |
| The browser queries the database directly (Supabase or Neon Data API, InsForge, Lovable Cloud, a Bolt database) | Yes, on every exposed table |
| The browser talks to Firebase, Appwrite, or PocketBase directly | Not RLS, but their rules on every collection or path |
| Only your server queries the database, and the browser holds no credential (floo, Convex functions, your own API) | No; optional as a second layer |
| Many customers share tables behind your server | Optional; worth considering as defense in depth |

If you are moving an app from the first row to the third, [here is how to add a backend to an app your agent built](https://getfloo.com/blog/how-to-add-a-backend-to-an-app-your-agent-built). The [backend overview](https://getfloo.com/backend) and the [templates](https://getfloo.com/templates) show the floo version, and [the pre-share checklist](https://getfloo.com/blog/backend-checklist-before-you-share-an-ai-built-app) covers the rest of what to check.

## Sources

- Supabase: [row-level security](https://supabase.com/docs/guides/database/postgres/row-level-security), [securing your API](https://supabase.com/docs/guides/api/securing-your-api), [REST API](https://supabase.com/docs/guides/api), [API keys](https://supabase.com/docs/guides/api/api-keys), [database advisors](https://supabase.com/docs/guides/database/database-advisors), [hardening the Data API](https://supabase.com/docs/guides/database/hardening-data-api)
- Neon: [Data API](https://neon.com/docs/data-api/overview), [Data API get started](https://neon.com/docs/data-api/get-started), [Data API access control](https://neon.com/docs/data-api/access-control), [row-level security](https://neon.com/docs/guides/row-level-security)
- InsForge: [FAQ](https://docs.insforge.dev/faq), [database](https://docs.insforge.dev/core-concepts/database/overview)
- Lovable: [Supabase integration](https://docs.lovable.dev/integrations/supabase), [external hosting](https://docs.lovable.dev/tips-tricks/external-deployment-hosting)
- Bolt: [database security](https://support.bolt.new/bolt-cloud/database/security)
- Firebase: [Security Rules basics](https://firebase.google.com/docs/rules/basics)
- Appwrite: [database permissions](https://appwrite.io/docs/products/databases/permissions)
- PocketBase: [API rules](https://pocketbase.io/docs/api-rules-and-filters/)
- Convex: [auth](https://docs.convex.dev/auth/overview), [internal functions](https://docs.convex.dev/functions/internal-functions), [row-level security (Convex blog)](https://stack.convex.dev/row-level-security)
- floo: [app sign-in](https://getfloo.com/docs/guides/app-auth), [app API keys](https://getfloo.com/docs/guides/app-api-keys), [Postgres](https://getfloo.com/docs/guides/postgres), [environment variables](https://getfloo.com/docs/guides/environment-variables), [networking](https://getfloo.com/docs/guides/networking)

## FAQ

### What is row-level security?

A Postgres feature that attaches policies to a table so each query only sees or changes the rows its caller is allowed to. With RLS enabled, a query from a signed-in user returns that user's rows even if the query asked for all of them.

### When do I need row-level security?

When something you do not control, usually the browser, queries the database directly. Supabase's Data API and Neon's Data API accept requests from the browser with a public key, so a policy on every exposed table is the only thing deciding what each visitor can read and write.

### Do I need row-level security if only my server queries the database?

No. If the browser holds no database credential and every query comes from your server code, authorization happens in that code: the server knows who is calling and filters accordingly. RLS can still be added as a second layer, but nothing is exposed without it.

### Do I need row-level security on floo?

No. floo gives the browser no database key and no data API. Requests pass floo's gateway, which signs people in and adds their identity in request headers, and only your server code holds DATABASE_URL. Your routes filter rows by the signed-in person.

### Doesn't a missing WHERE clause leak data too?

Yes. Moving the check into server code does not make it automatic. It puts it in one place per route, in code your agent can test with two signed-in users and you can read in a review, instead of in a policy per table that fails open when RLS is switched off or a policy lets everyone through.
