---
title: "Supabase security definer view: why it skips RLS"
description: "Why a Supabase view returns rows that RLS hides on its table, what the Advisor warning security_definer_view means, and the one setting that fixes it."
url: "https://madsalt.tech/notes/supabase-security-definer-view/"
author: "Denys Zaichenko"
author_url: "https://madsalt.tech/about/"
language: "en"
published: 2026-11-02
---

# Supabase security definer view: why it skips RLS

TL;DR

A Postgres view runs with the rights of its owner unless it is created with `security_invoker = true`. In Supabase the owner is usually `postgres`, which bypasses RLS, so a view over a protected table returns rows the table itself hides. On my local stack the public key got 0 rows from the table and all 3 of 3 from a plain view over it; with `security_invoker = true` the view returned 0 too. On Postgres 15 and later, set it on every view in an exposed schema.

My free [Supabase security checker](https://madsalt.tech/tools/supabase-security-check/) has a rule called "Views that bypass RLS", and Supabase's Security Advisor reports the same thing as `security_definer_view`. It is easy to miss, because the table it leaks from looks protected.

## Why does a Supabase view bypass RLS?

A view is a stored query. By default, Postgres checks permissions and RLS on the tables inside it as the view's owner, not as the person calling it. Supabase's [RLS guide](https://supabase.com/docs/guides/database/postgres/row-level-security) puts it directly: views bypass RLS by default because they are usually created with the `postgres` user, and a view over a protected table hands out every row its policies were meant to withhold.

The Data API serves views like tables. If the view is in an exposed schema and `anon` or `authenticated` can select from it, the public key reads it.

## What did the view return in my test?

On a local Supabase stack (CLI 2.109.1, Postgres 17.6, PostgREST 14.14) I created a table `orders` with three fake customers, enabled RLS with no policy, and a view `order_summary` over it with a `select` grant for `anon`. Then I called both with the publishable key:

![Same table, three ways to read it. Local Supabase, RLS on with no policy, publishable key. The table itself: Publishable key → orders, RLS on → 0 rows; A plain view over it: Publishable key → order_summary view → 3 of 3 rows; The view with security_invoker: Publishable key → security_invoker view → 0 rows.](https://madsalt.tech/charts/en/supabase-security-definer-view.svg)

*My reproduction on a local Supabase stack, 23 October 2026.*

| Request with the publishable key                             | Result                            |
| ------------------------------------------------------------ | --------------------------------- |
| `GET /rest/v1/orders` (RLS on, no policy)                    | `[]`                              |
| `GET /rest/v1/order_summary` (plain view)                    | all 3 rows, with names and emails |
| `GET /rest/v1/order_summary` after `security_invoker = true` | `[]`                              |

Same table, same key. Only the view's setting changed what came back.

## How do you fix it?

On Postgres 15 and later, which every current Supabase project runs, make the view check RLS as the caller:

```sql
alter view public.order_summary set (security_invoker = true);
```

For a new view, put it in the definition:

```sql
create view public.order_summary
with (security_invoker = true)
as select customer, email, total from public.orders;
```

The caller then also needs `select` on the underlying table, and the table's policies decide the rows. If a view must aggregate data the caller cannot see row by row, such as a public count, keep it out of the exposed schema and serve it through a function with a narrow result instead.

To list the views in `public` that still run as their owner:

```sql
select c.relname as view
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v'
  and not coalesce(
    (select bool_or(option = 'security_invoker=true' or option = 'security_invoker=on')
     from unnest(c.reloptions) as option), false);
```

On my stack, with one plain view and two with `security_invoker = true`, it returned only the plain one.

Materialized views do not support RLS at all, so the checker flags them in the API separately, under "Materialized views or foreign tables in the API". What else goes around policies, from the secret key to `SECURITY DEFINER` functions, is in my [Lovable app security checklist](https://madsalt.tech/blog/lovable-app-security-checklist/), and how to prove the policies themselves is in [how to test Supabase RLS](https://madsalt.tech/blog/how-to-test-supabase-rls/).
