Skip to content

Supabase's October 30 change: new public tables need a grant for Data API access. What to doSupabase's October 30 change is live: new public tables need a grant for Data API access. What broke and how to fix it

24 tools · 139 fixes · 42 issue guides · Sources: Supabase, GitHub, postgresql.org, Vercel + 53

Issues

Do Supabase views bypass RLS? How to check

Often, yes. By default a view reads its tables with its owner's permissions, so it skips RLS whenever that owner does. Set security_invoker = true.

Share link · Share on X

By ShipSecurely Editorial · Updated · How we make these guides

Shows up in: Supabase, Postgres

What it is

Often, yes. By default a Postgres view reads its tables with the view owner's permissions and policies, not the caller's. When that owner bypasses row level security, anyone who can select from the view gets every row its query returns. Setting security_invoker = true makes Postgres check the caller's permissions and policies instead.

If you are adding grants for Supabase's October 30, 2026 change, look at your views too. A select grant on a view in the default mode gives that role whatever the view's owner can read through it.

If a coding agent changed your views, Check your coding agent's security work shows how to confirm it, and Security checklist for AI client work covers views at a client handoff.

Do Supabase views bypass row level security?

By default, yes, when the view's owner bypasses row level security. Postgres's CREATE VIEW page says that by default "access to the underlying base relations referenced in the view is determined by the permissions of the view owner", and that when a table under the view has row level security enabled, "the row-level security policies of the view owner are applied". Postgres's Row Security Policies page says superusers and roles with BYPASSRLS always bypass row security. Table owners normally bypass it too, unless FORCE ROW LEVEL SECURITY is enabled; FORCE does not override superuser or BYPASSRLS bypass. GRANT says the owner of an object is usually the user that created it. So if one role, such as postgres, created both your tables and the view, the view reads those tables as their owner, and their policies normally do not filter the rows.

Supabase's Views guide puts it this way: "If a privileged role creates a view, everyone reading it does so through that role's permissions." Lint 0010, level ERROR, "View bypasses row-level security", says a view in public is accessible over your project's APIs, and that a view created through Supabase Studio or the Supabase CLI in this default mode will bypass row level security rules and could expose more data over the APIs than the developer intended.

The fix for a view that should respect row level security:

alter view public.your_view set (security_invoker = true);

After that, the caller needs its own grant on each table the view reads, and those tables' row level security policies apply to the caller. security_invoker needs Postgres 15 or later.

This does not mean every view leaks. It depends on who owns the view and who can select from it. The query further down shows both.

Why does a view use its owner's permissions?

The Views guide calls a view "a convenient shortcut to a query". Creating one adds no tables or data. When you read it, Postgres runs the underlying query and returns the results. The question is whose permissions that query runs with.

By default it is the view owner's. CREATE VIEW says this "can be used to provide secure but restricted access to the underlying tables". Lint 0010 calls it "an unintuitive default, chosen for backwards compatibility with older Postgres versions, which makes it easy to accidentally expose more data in views than was intended." Supabase's lints call this default mode SECURITY DEFINER.

With security_invoker set to true, CREATE VIEW says "access to the underlying base relations is determined by the permissions of the user executing the query, rather than the view owner", and the invoking user's row level security policies are used instead of the owner's. The Views guide says the same: the setting checks the querying role's permissions, "which is also what makes the underlying tables' row level security policies apply."

security_invoker arrived in Postgres 15. Lint 0002 says the setting was "introduced in Postgres 15", and the lint 0010 query only reports views on Postgres 15 or later. If you are not sure which version your project runs, select version(); in the SQL editor returns it (version information functions).

Supabase's Views guide example creates a plain transcripts view over three tables and then runs grant all on table transcripts to authenticated;. Its View security section, right below, explains the owner default and how to switch to security_invoker. If you copied the first part, read the second part too.

Keep views and functions apart. CREATE VIEW says functions called inside a view run with the privileges of the caller or of the function's owner, depending on whether each function is SECURITY INVOKER or SECURITY DEFINER, and the view's security_invoker setting does not change that. It also says a view with security_invoker set to false "is not equivalent to a SECURITY DEFINER function and those concepts should not be confused." For functions on the Data API, see Can anyone call your Supabase functions?

What does the October 30 change mean for views?

The October 30 changelog is about tables. On October 30, 2026 Supabase applies the new behavior to existing projects, and new tables in public need an explicit grant before the Data API can see them. The changelog does not say what this means for views. The points below are how Postgres privileges work, applied to that change.

  • New views follow the table defaults. Postgres's ALTER DEFAULT PRIVILEGES page says default privileges cover "tables (including views and foreign tables)". Securing your API lists the default privileges that new tables in public get on existing projects, including select, insert, update, and delete for anon, authenticated, and service_role. Put together: while your project still has those table defaults, a new view in public, created by the role whose default privileges grant that access, gets the same grants as a new table. Once the table defaults are revoked, by the changelog's opt-in SQL or on October 30, a new view needs an explicit grant, the same as a new table.
  • The rollback grants every view. The changelog's rollback runs grant select, insert, update, delete on all tables in schema public to anon, authenticated, service_role;. Postgres's GRANT page says "ALL TABLES also affects views and foreign tables". So that statement also grants every view in public to anon and authenticated. If you run it, check your views afterwards with the query below.
  • Existing views keep their grants. ALTER DEFAULT PRIVILEGES "does not affect privileges assigned to already-existing objects", and the changelog says existing objects keep their current grants. The changelog says the change touches default privileges in the public schema. Nothing in it sets security_invoker on a view.
  • Check the mode before you grant. In the default mode, Postgres's Rules and Privileges page says that, except for security invoker views, users only need privileges on the tables and views named in their own queries. A grant on a default-mode view is enough to read what the view's owner can read through it. If the rows should depend on who is asking, switch the view to security_invoker first.
  • An invoker view needs table grants. CREATE VIEW says "the user of a security invoker view must have the relevant permissions on the view and its underlying base relations." Once the October 30 behavior is on, a new table has no grant for anon or authenticated until you add one, so reading an invoker view over it fails for those roles until you grant select on the table. Supabase's 42501 guide says Postgres 42501 errors "imply the request lacked adequate privileges", and that an error like permission denied for table your_table means the querying role may not have the privilege it needs. The grant SQL is in Supabase's Oct 30 change: new tables aren't exposed automatically.

Which of my views can anon read?

This read-only query lists every view (v) and materialized view (m) in public, skipping ones an extension owns. It is adapted from the queries behind lints 0010 and 0016 in Supabase's open source linter, splinter, and uses the same security_invoker test.

select
    n.nspname as schema_name,
    c.relname as view_name,
    case c.relkind when 'v' then 'view' else 'materialized view' end as kind,
    pg_catalog.pg_get_userbyid(c.relowner) as owner,
    lower(coalesce(c.reloptions::text, '{}'))::text[]
        && array[
            'security_invoker=1',
            'security_invoker=true',
            'security_invoker=yes',
            'security_invoker=on'
        ] as security_invoker,
    pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') as anon_select,
    pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') as authenticated_select
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n
    on n.oid = c.relnamespace
left join pg_catalog.pg_depend dep
    on c.oid = dep.objid
    and dep.deptype = 'e'
    and dep.classid = 'pg_catalog.pg_class'::regclass
where c.relkind in ('v', 'm')
  and n.nspname = 'public'
  and dep.objid is null
order by c.relkind, c.relname;

has_table_privilege reports whether a role holds a privilege on a table, and pg_get_userbyid returns a role's name from its id, so owner is the role whose permissions a default-mode view uses.

This query inventories public only. When public is an exposed Data API schema, a regular view with security_invoker false and either SELECT flag true meets lint 0010's conditions on Postgres 15 or later; a materialized view with either flag true meets lint 0016's conditions. The lints also inspect other configured exposed schemas. These flags check table-level SELECT privileges, so false does not rule out access through column-level grants.

How to check in 5 minutes

  1. Open Security Advisor for the project. Look for lint 0010 security_definer_view (ERROR, "View bypasses row-level security"), lint 0016 materialized_view_in_api (WARN, "Materialized view exposed in API"), and lint 0002 auth_users_exposed (ERROR, "User data exposed through a view").

  2. In the SQL editor, run the query above. Write down every view with security_invoker false and anon_select or authenticated_select true, and every materialized view with either one true.

  3. For each one, check the view owner's privileges and ownership of the underlying tables. Superusers and roles with BYPASSRLS bypass their policies. A table owner also bypasses them unless that table has FORCE ROW LEVEL SECURITY enabled.

  4. Search the app for .from('your_view') and decide who should read each view: signed-out visitors, signed-in users, or only server code.

  5. If you ran the changelog's rollback, or any grant ... on all tables in schema public, run the query again. That grant reaches every view in public.

How to fix

Supabase

Fix each view on its own. Switch views that should respect row level security to security_invoker, and take materialized views with private rows off the API.

A view that should respect row level security. Switch it to the caller's permissions. The Views guide's statement for an existing view:

alter view public.your_view set (security_invoker = true);

For a new view, lint 0010 sets it at creation with with (security_invoker=on):

create view public.my_orders
    with (security_invoker = on)
    as
select id, user_id, total
from public.orders;

Then grant select on the view to the roles that use it, and make sure every table it reads has a grant for those roles, row level security on, and a policy. The table part follows the changelog's three steps:

grant select on public.my_orders to authenticated;

grant select on public.orders to authenticated;

alter table public.orders enable row level security;

create policy "users can read their own orders"
  on public.orders
  for select
  to authenticated
  using (auth.uid() = user_id);

The changelog says to treat those three steps as a unit: if the grant is missing, Postgres rejects the query before row level security comes into play. The example assumes orders has a user_id column.

A view over auth.users. Lint 0002 ("User data exposed through a view", ERROR) says a public security definer view referencing auth.users "exposes all user records to all API users". It gives two fixes:

  1. A public.profiles table with only the columns your app needs, filled by a trigger whenever a new user is inserted into auth.users, with row level security and policies on profiles. The lint warns that the trigger runs in the same transaction as the signup, so an error in it can block signups.
  2. A view with security_invoker=on, plus row level security and a policy on auth.users, so each user sees only the rows the policy allows.

Lint 0002 also flags materialized views over auth.users, because materialized views don't support row level security.

A materialized view. Lint 0016 says "Materialized views can't be protected by Row-Level Security, so all their data is visible to every API user." This warning concerns materialized views exposed through the API and selectable by the requesting role. It also says they can't be configured to respect the policies of the tables they are built on, so security_invoker is not the way out. The Views guide says a materialized view stores its results to disk. Take it off the API:

revoke select on public.your_mat_view from public, anon, authenticated;

Include PUBLIC, which grants access to all roles. Revoking a role's direct grant does not remove access it receives through PUBLIC. To confirm, this should return false:

select pg_catalog.has_table_privilege('anon', 'public.your_mat_view'::regclass::oid, 'select');

Run it again with 'authenticated' in place of 'anon'.

A view that is public on purpose. A view of data every visitor may see, like a public catalog, can stay as it is. It is public by design, so list only columns that are safe for anyone. The Views guide says you can keep sensitive columns out of a view by leaving them out of its query. That hides columns; it does not by itself enforce per-user row access. A default-mode view still uses its owner's permissions and policies for the underlying tables.

Postgres

Writes through a view. Postgres lets you insert, update, and delete through simple views. CREATE VIEW says the user needs the matching privilege on the view, and by default "the view's owner must have the relevant privileges on the underlying base relations, whereas the user performing the update does not need any permissions on the underlying base relations". With security_invoker set to true, the user doing the write needs those privileges instead. So writes through a default-mode view also go through the owner.

Can GraphQL see my views?

If your project uses pg_graphql, yes, for any view anon or authenticated can select. Lint 0026 says any table, view, materialized view, or foreign table that anon can SELECT is visible in pg_graphql introspection at /graphql/v1, regardless of row level security, and that this visibility is governed entirely by GRANT and REVOKE. Lint 0027 says the same for authenticated. To hide a view from a role's GraphQL introspection, remove every effective SELECT privilege on its columns, including access through PUBLIC or inherited roles, then repeat the introspection check as that role.

Copy the fix prompt

Paste this into Cursor, Lovable, Bolt, or Claude Code. Read the diff before you merge it.

You are auditing the views in this Supabase project. Do not add features. Do not run anything against my database without asking me first.

1. List every view and materialized view in the public schema, from the migrations in this repo and from the query results I paste back. For each one, record its owner, whether security_invoker is on, and whether anon or authenticated can select it.
2. Search the app for .from(' calls on each view. Say whether signed-out visitors (anon), signed-in users (authenticated), or only server code should read it.
3. Write one migration. For every view where row level security should apply, run alter view ... set (security_invoker = true).
4. For every materialized view that is not meant to be public, revoke select from public, anon, and authenticated in the same statement. Never revoke from anon alone.
5. For every view you switched to security_invoker, add the table grants the calling roles now need on each table it reads, and check that each of those tables has row level security enabled and a policy.
6. For any view that reads auth.users, stop and tell me. Do not grant anything on auth.users without asking.
7. Do not run grant ... on all tables in schema public. It also grants every view.

Output the SQL migration, then a table with one row per view: name, kind, owner, security_invoker before and after, who can select it before and after, who should read it, and the change you made.

Questions

Does enabling RLS on the table protect my view?

Not in the default mode when the view's owner bypasses row level security. CREATE VIEW says the view owner's policies are applied by default, and Postgres's row security docs say table owners normally bypass row security. With security_invoker = true, the policies and permissions of the user running the query are used, so the table's policies apply to each caller.

Will the October 30 change lock down my views?

Not the views you already have. Existing objects keep their grants, and the changelog's change is to default privileges, so it does not set security_invoker on any view. Postgres includes views in table default privileges, so new views stop receiving those grants when created by a role whose applicable defaults were revoked. The changelog's opt-in SQL targets objects created by postgres. Run the query above after any bulk grant.

Which Postgres version do I need for security_invoker?

Postgres 15 or later. Lint 0002 says the setting was introduced in Postgres 15, and the lint 0010 query checks for version 15 before it reports anything. Run select version(); in the SQL editor to see yours.

Sources

Related

Need someone to fix it for you? See /help.

The weekly fix

The weekly fix: 3 real security mistakes in vibe-coded apps and how to fix them, every Tuesday.

One email, every Tuesday. A welcome note follows the first signup.