# Supabase Row Level Security keeps AI-built mobile data safe from day one

> Source: <https://dev.to/davekurian/supabase-row-level-security-keeps-ai-built-mobile-data-safe-from-day-one-24ep>
> Published: 2026-09-07 06:01:00+00:00

AI-built mobile apps ship fast because the backend feels solved. You add Supabase auth, create a few tables, paste the anon key into your Expo config, and rows start flowing. That speed hides one uncomfortable fact: the anon key ships inside your client, and anyone can pull it out of the bundle and call your database directly.

Row Level Security is the layer that makes that safe. It runs inside Postgres, on every query, no matter which client or script sent it. When it is set up well, a signed-in user only ever sees their own rows, anonymous visitors see only what is truly public, and everything else is denied before it leaves the database.

When it is missing, your app works perfectly in testing and leaks quietly in production. You will not get an error. You will just have tables that answer anyone who asks.

A web app can hide a service key on a server. A mobile app cannot. Your Expo build contains the publishable anon key by design, and Supabase maps every request from that key to either the `anon` role or the `authenticated` role. Grants decide whether that role can run an operation at all. Policies decide which rows the operation touches.

Both have to be right. A policy without the correct grant fails closed with a permission error. A grant without a policy fails open and hands out rows. Most AI-generated schemas land in the second camp: tables created quickly, RLS never enabled, default grants left wide open.

If you take one habit from this post, let it be this: every table in an exposed schema gets RLS enabled, minimal grants, and at least one policy per operation you actually use. No exceptions for lookup tables, no exceptions for v1 prototypes.

For background on hardening the auth side first, read [the production sign-in checklist](https://dev.to/blog/supabase-auth-production-hardening). This post picks up where that one ends, at the database boundary.

Think of access control as two gates in a row. The request hits grants first, then policies.

Grants are coarse. They say a role may run `select` or `insert` on a table. Policies are precise. They add a hidden `WHERE` clause to every query, usually comparing a row owner column against the signed-in user id from the JWT.

A typical personal-data pattern looks like this:

```
alter table public.reports enable row level security;

revoke all on table public.reports from anon, authenticated;

grant select, insert, update, delete
  on table public.reports
  to authenticated;

create policy "Users manage their own reports"
  on public.reports
  for all
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
```

The `using` clause filters reads, updates, and deletes. The `with check` clause validates writes. Using `for all` with both clauses keeps the rule symmetric so a user cannot insert a row they could never read back, or update someone else's row by guessing its id.

Keep `service_role` out of the client entirely. It bypasses RLS by design and belongs only in server-side code such as trusted edge logic. If you need privileged writes from mobile flows, route them through [edge functions that hold backend logic](https://dev.to/blog/supabase-edge-functions-mobile-backend) instead of widening a client policy.

The simplest durable pattern for AI-built apps is owner-based access. Every user-owned table gets a `user_id` column defaulting to the request user, and every policy compares against `auth.uid()`.

```
-- New rows automatically belong to the caller
alter table public.todos
  alter column user_id set default (select auth.uid());
```

This removes a whole class of client bugs where the app forgets to send the owner id or sends the wrong one. The database fills it in from the JWT, and the policy enforces it on the way back out.

Wrap inserts defensively even when the default exists:

```
create policy "Users insert their own todos"
  on public.todos
  for insert
  to authenticated
  with check ((select auth.uid()) = user_id);
```

Test the negative case, not just the happy path. Sign in as user A, try to read user B's row id directly, try to update it, try to delete it. All three should return nothing or deny. If your test suite only checks that owners can read their own data, you have tested half the policy.

Many apps need mixed access: public profiles anyone can view, but only the owner can edit. Do not solve this with one permissive policy. Write one narrow policy per operation.

```
create policy "Profiles are viewable by everyone"
  on public.profiles
  for select
  to authenticated, anon
  using (true);

create policy "Users update their own profile"
  on public.profiles
  for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);
```

Notice the `select` policy explicitly names both roles. That is intentional. An `anon` grant you forgot to revoke combined with a loose select policy is how public read becomes public everything. Scope `anon` to read-only on truly public tables and to nothing everywhere else:

```
revoke all on table public.profiles from anon;
grant select on table public.profiles to anon;
```

If a table should never be visible to signed-out visitors, give `anon` no grant at all. Absence of a grant is a security control, not an oversight.

Solo-owner policies stop scaling the moment you add shared projects, organizations, or family accounts. The next step is a membership table and a policy that joins through it.

The shape is almost always the same: a `workspace_members` table with `workspace_id` and `user_id`, and data tables carrying `workspace_id`. Policies check membership instead of direct ownership:

```
create policy "Members read workspace documents"
  on public.documents
  for select
  to authenticated
  using (
    exists (
      select 1
      from public.workspace_members m
      where m.workspace_id = documents.workspace_id
        and m.user_id = (select auth.uid())
    )
  );
```

Keep the membership check in a small helper function with `security definer` and a fixed search path when you reuse it across tables, so every policy reads the same way and performs consistently. Avoid per-row subqueries that scan large tables without indexes on `workspace_id` and `user_id`. RLS runs on every row the query touches, so a slow policy is a slow app.

Separate read membership from admin actions. Viewing a document and deleting a workspace are different privileges and deserve different policies, ideally with a role column on the membership row distinguishing members from owners.

Views bypass RLS by default when they run with elevated privileges, which surprises builders who secured the base table and then exposed a convenient view over it. Treat every view as its own surface: either make it run with the caller's permissions so underlying policies still apply, or apply equally strict rules to the view itself.

The same caution applies to database functions. A function that runs as its owner can sidestep the policies you just wrote unless you designed it to. Prefer functions that execute as the caller for user-scoped reads, and reserve elevated functions for narrow server-side jobs with explicit input validation.

A practical audit is short. List every view and function touching user data, note which privilege mode each uses, and confirm that no public path returns rows the base policy would deny. If you cannot answer that in one sitting, you have too many privileged helpers.

RLS without tests is a hope, not a control. For each table, assert allow and deny for `select`, `insert`, `update`, and `delete`, for both `anon` and `authenticated`, with at least two distinct users.

A workable minimum per table looks like this:

Store these as SQL test files next to your migrations so they run with your normal database test command. When a future AI edit adds a column or widens a grant, the suite catches the regression before it reaches production. Until the suite passes, you do not know whether the policies do what you intended.

Pair this with an offline plan on the client. A denied request should surface as a clear signed-out or permission state, not a spinner. The [offline-first mutation queue pattern](https://dev.to/blog/offline-first-mutation-queue-expo) helps here because queued writes replay after re-auth instead of failing silently against a policy the user tripped while signed out.

First, enabling RLS but leaving default grants wide open. Policies alone do not revoke anything. Revoke first, then grant back only what each role needs.

Second, writing a permissive `using (true)` select policy for debugging and shipping it. Debug policies have a way of becoming permanent. Delete them or gate them behind a real condition before merge.

Third, forgetting `with check` on writes. A table with a read filter but no write check lets users insert rows owned by someone else, which then vanish from their own reads and appear in the victim's feed.

Fourth, trusting client-side filtering. Hiding other users' data in the UI while the API still returns it is not access control. If the row reaches the client, it is already exposed.

Fifth, widening a policy to fix a broken screen instead of fixing the grant or the JWT. When a legitimate request fails, check grants before policies, confirm the user is actually authenticated with the role you expect, and only then adjust the rule. Each emergency widening should come with a test that pins the intended behavior.

Start with inventory. List every table in your exposed schema and mark whether RLS is enabled, which roles hold which grants, and which operations each policy covers. Most AI-built codebases find at least one table with no policies and one role with grants it never uses.

Then work table by table. Enable RLS, revoke broad grants, restore minimal ones, write one policy per operation, add owner defaults where they fit, and commit the migration with its test file. Run the suite, fix what it reports, and re-check the app screens that touch the table as both a signed-in owner and a signed-out visitor.

Finish with the edges. Confirm no view or elevated function leaks rows around your new policies, confirm `service_role` appears nowhere in client code or bundled config, and confirm your error states distinguish permission denied from network failure so users know to sign in again rather than retry forever.

That is a real afternoon of work, and it converts your database from implicitly open to explicitly closed. Future features then inherit the boundary instead of renegotiating it each time.

Primary source verified live today: [Supabase Row Level Security guide](https://supabase.com/docs/guides/database/postgres/row-level-security) covering grants plus policies, the per-table procedure, and helper patterns. Internal references: [the production sign-in checklist](https://dev.to/blog/supabase-auth-production-hardening), [edge functions for backend logic](https://dev.to/blog/supabase-edge-functions-mobile-backend), and [the offline-first mutation queue pattern](https://dev.to/blog/offline-first-mutation-queue-expo).
